PHP has arrays.
They are flexible, fast, convenient… and sometimes far too permissive.
When a project grows, an array<int, mixed> can quietly become an array of strings, objects, missing keys, duplicated values, invalid states, or combinations nobody intended.
That is why I have been working on small/collection, a PHP collection library designed around a simple idea:
A collection should not only contain values. It should express and enforce what those values mean.
Recently, I extended the library with several specialized collection types and, perhaps more importantly, tightened its static type system with PHPStan generics.
This article covers some of the most interesting lessons from that work.
The base collection
The core collection uses two generic parameters:
/**
* @template TKey of array-key
* @template TValue
*
* @implements \ArrayAccess<TKey, TValue>
* @implements \Iterator<TKey, TValue>
*/
class Collection implements
\ArrayAccess,
\Countable,
\Iterator,
\JsonSerializable
{
/** @var array<TKey, TValue> */
protected array $array = [];
}
That already gives PHPStan much more information than:
class Collection
{
protected array $array = [];
}
Instead of treating every collection as mixed, we can describe things such as:
/** @var Collection<string, User> $users */
$users = new Collection();
PHPStan now knows:
- keys are strings
- values are
User - iterator values are
User -
offsetGet()returnsUser - callbacks can receive typed keys and values
But things become more interesting when specialized collections start inheriting from this class.
ListCollection: keys are part of the invariant
A list is not just a collection with integer keys.
A list should guarantee:
0, 1, 2, 3, ...
No gaps.
No string keys.
So ListCollection normalizes and protects its indexes.
/**
* @template TValue
* @extends Collection<int, TValue>
*/
class ListCollection extends Collection
{
}
Its API includes operations such as:
$list->append($value);
$list->prepend($value);
$list->insertAt(2, $value);
$list->removeAt(1);
$list->at(3);
$list->shuffle();
The important part is not the convenience methods.
The important part is that the invariant remains true after every operation.
$list = new ListCollection([
7 => 'foo',
42 => 'bar',
]);
$list->toArray();
// [
// 0 => 'foo',
// 1 => 'bar',
// ]
This distinction between a generic collection and a list becomes extremely useful once static analysis is involved.
SetCollection: uniqueness belongs in the type
A set should never contain duplicates.
Instead of expecting every caller to remember:
array_unique($values);
the collection itself owns the invariant.
The API can then naturally expose set operations:
$set->union($other);
$set->intersection($other);
$set->difference($other);
$set->symmetricDifference($other);
$set->isSubsetOf($other);
$set->isSupersetOf($other);
$set->equalsSet($other);
One design decision matters here: equality.
For this library, set uniqueness uses strict PHP equality:
$value === $other
That means:
2 !== '2'
and therefore both values may coexist in a set.
This is intentional.
Type-sensitive collections should generally avoid PHP's loose comparison rules unless coercion is explicitly part of the API.
MapCollection: map and list are different abstractions
PHP arrays blur the distinction between lists and maps.
A collection API does not have to.
A MapCollection accepts explicit int|string keys and exposes map-oriented operations:
$map->getOrDefault('timeout', 30);
$map->require('database');
$map->renameKey('old', 'new');
$map->mapKeys(...);
$map->mapValues(...);
$map->hasAllKeys(...);
$map->hasAnyKey(...);
One of the interesting PHPStan improvements was typing mapKeys() correctly.
A bad annotation would be:
/**
* @return MapCollection<TKey, TValue>
*/
public function mapKeys(callable $callback): MapCollection
That is wrong because the callback can change the key type.
The better version introduces a method-level template:
/**
* @template TMappedKey of array-key
*
* @param callable(TKey, TValue): TMappedKey $callback
* @return static<TMappedKey, TValue>
*/
public function mapKeys(callable $callback): static
Now PHPStan can understand:
/** @var MapCollection<string, User> $users */
$usersById = $users->mapKeys(
fn (string $email, User $user): int => $user->id,
);
and infer something equivalent to:
MapCollection<int, User>
This is where generics stop being documentation and start becoming part of the developer experience.
EnumCollection and one of PHPStan's subtle generic traps
Enums are perfect candidates for specialized collections.
/**
* @template TKey of array-key
* @template TEnum of \UnitEnum
*
* @extends Collection<TKey, TEnum>
*/
class EnumCollection extends Collection
{
}
The collection guarantees two things:
- every value implements
UnitEnum - all values belong to the same enum class
Example:
enum Status
{
case Draft;
case Published;
}
$statuses = new EnumCollection([
Status::Draft,
Status::Published,
]);
Factories make this convenient:
$statuses = EnumCollection::fromNames(
Status::class,
['Draft', 'Published'],
);
For backed enums:
enum Role: string
{
case User = 'user';
case Admin = 'admin';
}
$roles = EnumCollection::fromValues(
Role::class,
['user', 'admin'],
);
The generic property problem
An interesting PHPStan error appeared around the internal enum class:
/** @var class-string<TEnum>|null */
private ?string $enumClass = null;
At runtime, this looked reasonable.
But checkValue() receives:
mixed
and only later verifies:
$value instanceof \UnitEnum
PHPStan correctly pointed out that:
class-string<UnitEnum>
is not necessarily:
class-string<TEnum>
The fix was not to silence PHPStan.
The correct fix was to model the internal runtime state honestly:
/** @var class-string<\UnitEnum>|null */
private ?string $enumClass = null;
The public collection still has:
TEnum
but the internal invariant tracker only needs to know that it stores the class name of some enum.
This is an important lesson:
A generic parameter should only be used where the program can actually guarantee that relationship.
More precise-looking PHPDoc is not automatically more correct.
DateTimeCollection: preserve the concrete date type
A first attempt might look like this:
/**
* @template TKey of array-key
* @extends Collection<TKey, \DateTimeInterface>
*/
class DateTimeCollection extends Collection
{
}
This works, but loses useful information.
If the caller gives the collection only DateTimeImmutable objects, methods such as:
earliest()
latest()
closestTo()
should ideally return:
DateTimeImmutable|null
not just:
DateTimeInterface|null
A better declaration is:
/**
* @template TKey of array-key
* @template TDateTime of \DateTimeInterface
*
* @extends Collection<TKey, TDateTime>
*/
class DateTimeCollection extends Collection
{
}
Now methods can return:
/** @return TDateTime|null */
public function earliest(): ?\DateTimeInterface
The runtime signature remains compatible with PHP, while PHPStan preserves the more precise generic type.
The collection also exposes domain-appropriate operations:
$dates->earliest();
$dates->latest();
$dates->between($from, $to);
$dates->before($date);
$dates->after($date);
$dates->sortChronologically();
$dates->groupByDay();
$dates->groupByMonth();
$dates->closestTo($date);
A collection becomes much more valuable when its API speaks the language of its values.
BooleanCollection
A boolean collection sounds simple, but a dedicated type makes aggregate logic much clearer:
$flags->allTrue();
$flags->anyTrue();
$flags->noneTrue();
$flags->countTrue();
$flags->countFalse();
$flags->and();
$flags->or();
$flags->xor();
$inverted = $flags->invert();
An interesting semantic choice is xor().
For the collection implementation, XOR follows parity:
true when an odd number of values are true
This generalizes the normal two-value XOR operation.
IntegerCollection and FloatCollection should not be the same thing
It is tempting to create one numeric collection and stop there.
But integers and floats have very different operations.
IntegerCollection
Integer-specific operations include:
IntegerCollection::range(1, 100);
$numbers->gcd();
$numbers->lcm();
$numbers->evenValues();
$numbers->oddValues();
$numbers->bitAnd();
$numbers->bitOr();
$numbers->bitXor();
$numbers->median();
$numbers->mode();
Bitwise operations were also a good reminder that tests themselves can be wrong.
For example:
6 & 3
is:
2
not 0.
A complete test suite is useful not only for discovering implementation bugs, but also for challenging incorrect assumptions in test expectations.
FloatCollection
Floating point values introduce another category of concerns:
$floats->epsilonEquals($other, 0.00001);
$floats->round(2);
$floats->floor();
$floats->ceil();
$floats->isFinite();
$floats->withoutNan();
A floating-point collection should also define explicit semantics for:
NAN
INF
-INF
Leaving those cases implicit eventually creates surprises.
ObjectCollection
An object collection can provide generic object-oriented operations without becoming domain specific:
$objects->instancesOf(User::class);
$names = $objects->property('name');
$results = $objects->method('calculate');
$indexed = $objects->indexByProperty('id');
The important design decision was to keep it generic.
I deliberately did not add collections such as:
EmailCollection
UuidCollection
UrlCollection
MoneyCollection
Those belong either in domain packages or value-object libraries.
A reusable collection library should provide structural specializations, not try to predict every application domain.
SortedCollection: sorting as a permanent invariant
Sorting a normal collection is an operation:
$collection->sortByCallback($comparator);
A SortedCollection is different.
Sorting is part of the type invariant.
Once a comparator is configured, every insertion or replacement must preserve order.
$sorted = new SortedCollection(
values: $items,
comparator: fn ($a, $b) => $a->priority <=> $b->priority,
);
After:
$sorted->set('new', $item);
the collection must still be sorted.
This illustrates a useful distinction:
Operations describe what a collection can do. Invariants describe what a collection is.
ImmutableCollection: immutability is an API family
Immutability is different from IntegerCollection or EnumCollection.
It is not primarily about value type.
It changes the mutation model.
Instead of:
$collection['foo'] = 'bar';
an immutable API uses:
$newCollection = $collection->with('foo', 'bar');
Other operations include:
$new = $collection->without('foo');
$new = $collection->appended('bar');
Direct mutations throw:
$collection['foo'] = 'bar';
unset($collection['foo']);
$collection->push('bar');
This is why I implemented immutability as both:
ImmutableCollection
and:
ImmutableCollectionInterface
rather than as another value specialization.
static is extremely useful in collection PHPDoc
Suppose a base method creates:
new static()
and preserves keys and values.
The return type should often be:
@return static
instead of:
@return Collection<TKey, TValue>
For example:
/**
* @return static
*/
public function filterByCallback(callable $callback): Collection
Now this:
/** @var DateTimeCollection<int, DateTimeImmutable> $dates */
$result = $dates->filterByCallback(...);
can remain a DateTimeCollection from the static analyzer's point of view.
This matters for fluent APIs.
Without it, every inherited operation slowly degrades back to the base Collection.
But do not use static when the generic shape changes
There is a catch.
Consider:
values()
It removes the original keys and produces integer indexes.
If we start with:
Collection<string, User>
the result is conceptually:
Collection<int, User>
Returning simply:
static
would preserve the subclass, but not correctly express the changed generic key.
Sometimes PHP/PHPStan cannot express every relationship we would ideally like.
The goal is not maximum cleverness.
The goal is the most accurate contract the type system can honestly represent.
Conditional return types for toArray()
The base collection has:
toArray(
bool $keepKey = true,
bool $recursive = true,
)
The result changes depending on the arguments.
Instead of declaring only:
@return array
PHPStan can model it with a conditional type:
/**
* @return array<array-key, mixed>
*
* @phpstan-return (
* $recursive is false
* ? (
* $keepKey is true
* ? array<TKey, TValue>
* : list<TValue>
* )
* : (
* $keepKey is true
* ? array<TKey, mixed>
* : list<mixed>
* )
* )
*/
public function toArray(
bool $keepKey = true,
bool $recursive = true,
): array
This also allowed removing an old:
@phpstan-ignore-next-line
because PHPStan could finally understand the actual contract.
That is one of my favorite outcomes of better type documentation:
Good types can remove static-analysis suppressions instead of adding more of them.
RecordCollection needed more than one template
Another interesting case was a collection of records.
A simplistic declaration would be:
/**
* @template TKey of array-key
* @extends Collection<TKey, Record>
*/
class RecordCollection extends Collection
{
}
But Record itself is generic.
So the useful declaration becomes:
/**
* @template TKey of array-key
* @template TRecordKey of array-key
* @template TRecordValue
*
* @extends Collection<
* TKey,
* Record<TRecordKey, TRecordValue>
* >
*/
class RecordCollection extends Collection
{
}
Those templates then need to propagate into:
- selectors
- conditions
- brackets
- record adapters
- transformations
Generics are only as good as their weakest propagation point.
If one intermediate class falls back to:
RecordCollection
without parameters, much of the static information disappears.
One surprising Selector detail
While auditing the selector API, I initially expected:
execute()
to preserve the source collection keys.
Then I checked the implementation.
It does:
$result[] = $record;
That means the result is reindexed.
So the correct return type is closer to:
RecordCollection<int, TRecordKey, TRecordValue>
not:
RecordCollection<TKey, TRecordKey, TRecordValue>
The lesson is simple:
Do not write PHPDoc based on what an API looks like it should do. Type what the runtime actually does.
Static analysis is especially valuable here because it forces API assumptions to become explicit.
When PHPStan finds a design problem instead of a PHPDoc problem
Not everything can be solved with annotations.
NumericCollection is a good example.
Its runtime currently accepts values using:
is_numeric($value)
which includes:
42
42.5
"42"
"42.5"
So a completely honest type would include:
numeric-string
But then consider:
IntegerCollection extends NumericCollection
FloatCollection extends NumericCollection
If we propagate:
@template TNumeric of int|float|numeric-string
we create awkward contracts in the specialized classes.
There is another issue.
Even if:
IntegerCollection
starts with integers, an inherited operation such as division can produce floats.
So this:
/**
* @extends NumericCollection<TKey, int>
*/
would be misleading unless arithmetic operations are redesigned.
This is not a PHPDoc problem anymore.
It is an API architecture question.
Possible future directions include:
- arithmetic methods returning a different collection type
-
IntegerCollection::divide()returningFloatCollection - separating integer-preserving and numeric-promoting operations
- tightening
NumericCollectionruntime validation
For now, I prefer leaving a known architectural limitation over publishing a beautiful but false type contract.
Static analysis and runtime tests complement each other
For this work, the validation target is strict:
PHPStan: 0 errors
196 tests
786 assertions
100% line coverage
The important part is that these tools catch different classes of problems.
PHPStan found things such as:
class-string<TEnum>
being assigned a value that could only be proven as:
class-string<UnitEnum>
Runtime tests found things such as:
- incorrect bitwise expectations
- incorrect inclusive DateTime boundary expectations
- assumptions about nested collections
- sorting callbacks receiving
Collectionobjects instead of raw arrays
Coverage found branches that had never actually been exercised.
None of these tools replaces the others.
Avoid using PHPStan ignores as type design
There are legitimate cases for:
@phpstan-ignore-next-line
especially around highly dynamic code.
But during this refactor I found several places where better PHPDoc made ignores unnecessary.
That should usually be the preference.
Before adding:
@phpstan-ignore-next-line
ask:
- Is the code actually safe?
- Can the type be expressed better?
- Is the implementation violating its documented generic contract?
- Is this exposing a real API design problem?
A static analyzer complaining about generic variance is often telling you something meaningful.
Specialized collections are more than helper methods
The biggest lesson from this work is that specialized collections are useful because they combine three things:
Runtime invariants
ListCollection
→ consecutive integer indexes
SetCollection
→ unique values
EnumCollection
→ one enum class
IntegerCollection
→ integer values
SortedCollection
→ permanent ordering
ImmutableCollection
→ no direct mutation
Domain-appropriate APIs
DateTimeCollection::earliest()
SetCollection::intersection()
MapCollection::renameKey()
BooleanCollection::allTrue()
Static contracts
@template
@extends
@implements
class-string<T>
conditional return types
static return types
When those three layers agree, collections become significantly more useful than typed wrappers around arrays.
Final thoughts
PHP's type system is much stronger today than it used to be, but advanced collection APIs still rely heavily on static-analysis tools such as PHPStan.
Used carefully, PHPDoc generics can express relationships PHP itself cannot yet encode:
Collection<TKey, TValue>
MapCollection<TKey, TValue>
DateTimeCollection<TKey, TDateTime>
EnumCollection<TKey, TEnum>
RecordCollection<TKey, TRecordKey, TRecordValue>
The important word is carefully.
The goal should never be:
How can I make PHPStan stop complaining?
The better question is:
What contract does this code actually guarantee?
When the annotation follows that answer, PHPStan becomes less of a linter and more of an API design tool.
And that is where generics become really interesting in PHP.
Links
Repository : https://git.small-project.dev/lib/small-collection
Packagist : https://packagist.org/packages/small/collection
Top comments (0)