webparking/laravel-type-safe-collection
Webparking's Laravel Type-Safe Collection adds strongly typed collections to Laravel. Define the permitted item class once to get runtime type checks, clearer errors, IDE autocompletion, and safer map/filter operations, keeping collection contents predictable and preventing mixed-type bugs.
Install with Composer; there is nothing to publish or register:
composer require webparking/laravel-type-safe-collection
The package is a single abstract class, Webparking\TypeSafeCollection\TypedCollection, that extends Illuminate\Support\Collection. Read the README first (it is one page), then skim the class source — it is small enough to audit in a few minutes, which matters given the last release was September 2020.
First use case — a collection for one model:
namespace App\Collections;
use App\Models\User;
use Webparking\TypeSafeCollection\TypedCollection;
class UserCollection extends TypedCollection
{
protected $type = User::class;
}
$users = new UserCollection([$userA, $userB]);
$users->push($userC); // fine
$users->push('not a user'); // throws InvalidArgumentException
The type is declared once per subclass; every write path (push, put, prepend, offsetSet, constructor) is guarded, so once you have a UserCollection you can stop writing instanceof checks in downstream code.
Wire it into Eloquent immediately — otherwise you will only ever create these by hand:
class User extends Model
{
public function newCollection(array $models = [])
{
return new UserCollection($models);
}
}
After that, User::all(), User::where(...)->get(), and any HasMany/BelongsToMany relation returning users are all UserCollection instances.
One collection class per model, with domain methods on it. This is where the package pays off — the collection becomes a typed home for query-shaped logic:
class UserCollection extends TypedCollection
{
protected $type = User::class;
public function active(): self
{
return $this->filter(fn (User $u) => $u->isActive());
}
public function emails(): \Illuminate\Support\Collection
{
return $this->toBase()->pluck('email');
}
}
Note active() returns self (safe — filter preserves element type and collection class), while emails() deliberately drops to a base collection via toBase().
Use it as a return type / parameter type. Because the invariant is enforced at construction and mutation, a UserCollection parameter is a genuine contract:
function sendDigest(UserCollection $recipients): void
{
$recipients->each(fn (User $u) => $u->notify(new DigestNotification));
}
Stable chain operations. These return the same typed class with the same element type, so they are safe to chain:
filter, where*, reject, sortBy/sortByDesc, unique, values, take, slice, reverse, each, tap, when, unless, shuffle, merge/concat (with same-type items only).
Transform-and-downgrade pattern. For anything that changes the element shape, convert first and convert back only if the result is the same type:
$names = $users->toBase()->pluck('name'); // base collection of strings
$emails = collect($users)->map->email; // collect() always returns base
$admins = UserCollection::make(
$users->toBase()->filter(fn (User $u) => $u->isAdmin())->all()
);
Map into another typed collection. mapInto() returns new static, so mapping users into a different type will throw; map into the same type or go through toBase():
$dtos = $users->toBase()->mapInto(UserDto::class); // base collection of DTOs
Repository / action returns. Returning UserCollection from repositories makes the type visible in the signature and pushes validation to the boundary — exactly where you want it. Combine with mapInto/map inside the repository and downgrade at the edge.
Testing ergonomics. Because failures are exceptions rather than silent corruption, tests are short:
$this->expectException(\InvalidArgumentException::class);
new UserCollection(['oops']);
newCollection() changes more than the element type. TypedCollection extends the base Illuminate\Support\Collection, not Illuminate\Database\Eloquent\Collection. Overriding newCollection() therefore removes Eloquent collection methods from relation results: modelKeys(), find(), load(), contains($model), fresh(), toQuery(), diff() on models, etc. If your code relies on those, either extend the Eloquent collection instead, keep a separate accessor, or only override newCollection() on models whose relations you never call those methods on. Verify this against the version you install before rolling out widely.
pluck/keys/flip/zip/collapse will throw on typed collections. Any method implemented as new static(...) with non-matching items trips the guard. Day-to-day this most often bites on $user->posts->pluck('id') — rewrite as $user->posts->toBase()->pluck('id') or $user->posts()->pluck('id') (the query builder returns a base collection).
chunk, split, and sliding nest collections, so the outer collection contains TypedCollection instances rather than models and the type check fails. Use the query builder's chunk() for batch processing instead.
toBase() is the supported escape hatch, not a hack. Adopt the convention: transform on a base collection, re-wrap with UserCollection::make($items->all()) at the end.
Variadic push. push(...$items) expects individual arguments. $collection->push($arrayOfUsers) tries to insert the array as one item and throws — use push(...$arrayOfUsers) or concat.
The empty-collection problem. A type cannot be inferred from [], so the $type property must be set on every subclass. If you forget it, you get an error (or a permanently permissive collection, depending on the version) — add a test that constructs the empty collection.
Static analysis. The class predates broad generics support, so annotate your subclasses to get IDE and PHPStan/Psalm value:
/** @extends \Webparking\TypeSafeCollection\TypedCollection<int, User> */
class UserCollection extends TypedCollection { /* ... */ }
Debugging a failure. The exception message names the expected type and the offending value. If it is opaque, dd(array_map('get_class', $collection->all())) at the throw site. Wrap suspicious third-party code that returns collections in collect() to downgrade before it reaches your typed layer.
Maintenance risk. Last release 2020-09-10, Laravel 8 era. Check compatibility with your Laravel version — Collection internals (new static usage, mapInto, lazy) have shifted since. The class is tiny; if a Laravel upgrade breaks it, vendoring the class into app/Support and adjusting is usually cheaper than waiting for a release. Pin the version in composer.json and cover your subclass contract with a couple of tests so upgrades surface regressions.
Do not apply it to LazyCollection. The package targets Collection; lazy pipelines are not covered, and the guard on chunk-style operations makes it a poor fit anyway.
Extension points are limited by design. There is essentially one: the $type declaration. Add behaviour by writing methods on your subclass (returning self for same-type operations, base collections for shape changes), not by overriding the mutation methods — overriding them defeats the guarantee you adopted the package for.
How can I help you explore Laravel packages today?