The discussion is almost as old as PHP 5: Do we need generics? The article "Compile time generics: yay or nay?" by Gina Banyard and Larry Garfield brought this topic back into focus last summer.
A year earlier, in August 2024, Arnaud Le Blanc, Derick Rethans, and Larry Garfield showed in their article "State of Generics and Collections" how difficult "real" generics are from a technical point of view (keyword: super-linear time complexity).
My position on this may surprise some: I am not sure I really need generics in this form.
Just to be clear: This article does not argue against the introduction of generics in PHP. Rather, it shows that PHP already offers a surprisingly high level of support for the very common case of type-safe collections.
Generics
Generics are among the most powerful features of modern programming languages such as Java, C#, TypeScript, Swift, Rust, and Go. The best-known use cases are collections such as List<Type> or Map<Key, Value>.
The most fundamental advantage of generics is that they shift the responsibility for type safety from runtime to compile time. This allows the compiler to detect type errors during compilation. Explicit type casting is therefore no longer necessary, and the likelihood of errors at runtime is drastically reduced.
The documentation for the Dart programming language explicitly states this, for example:
Generics let you share a single interface and implementation between many types, while still taking advantage of static analysis.
Generic programming has far more diverse applications than just type-safe collections. It forms the basis of numerous design patterns, frameworks, and architectures in programming languages that support this paradigm. Examples include dependency injection, asynchronous programming, and functional patterns such as monads. Generics enable code that is expressive, maintainable, and high-performance.
In this article, however, I will limit myself to type-safe collections. This is not because I want to dismiss the other use cases mentioned, but because I am mostly interested in this one.
Pragmatic without generics
If I am not sure whether I really need generics in this form, what do I want?
What I want is type safety. I want to be able to guarantee that a collection only contains objects of a certain type. If I look honestly at my code, I do not need new syntax in PHP for this. All I need is discipline and the features that PHP already offers us today.
Almost everything we miss in generics can be solved by two things:
- Type annotations for static code analysis with PHPStan or another tool
- Type-safe collections for runtime safety
I would like to show here how point 2 already works today without compromise and why I prefer my solution to the proposal for native fluid arrays. Let us take a look at the canonical "generic collection" use case, List<T>, and see how far we can get in plain PHP.
A gatekeeper for the input
A common objection to userland implementations is:
I do not want to check every element in a
foreachloop, that is arduous.
We do not have to. PHP provides us with native, optimised type checking in the form of the unpacking operator ... and variadic constructors. Here is my implementation of a TestCollection:
<?php declare(strict_types=1); /** * @template-implements IteratorAggregate<non-negative-int, Test> * * @immutable */ final readonly class TestCollection implements IteratorAggregate { /** * @var list<Test> */ private array $tests; /** * @param list<Test> $tests */ public static function fromArray(array $tests): self { assert(array_is_list($tests)); return new self(...$tests); } private function __construct(Test ...$tests) { $this->tests = $tests; } /** * @return list<Test> */ public function asArray(): array { return $this->tests; } public function getIterator(): TestCollectionIterator { return new TestCollectionIterator($this); } }
declare(strict_types=1) enables strict interpretation of scalar type declarations for arguments and return values throughout the file.
@template-implements is an annotation for generic type parameters:
-
IteratorAggregateis a built-in PHP interface with two generic type parameters:TKey(key type) andTValue(value type).Objects that implement
IteratorAggregateare iterable by returning anIteratorobject from theirgetIterator()method, enabling their use inforeachloops and other iteration constructs without implementing theIteratorinterface directly. -
@template-implements IteratorAggregate<non-negative-int, Test>tells static analysis tools how this object is iterated:-
Keys will always be
non-negative-int(integers ≥ 0) -
Values will always be
Testobjects
-
Keys will always be
This enables a static analysis tool to verify that when you iterate over this collection, you will always get integer keys and Test objects, catching type errors at analysis time rather than runtime.
The @immutable annotation signals to static analysis tools that instances of this class never change after construction. Combined with readonly, this provides strong immutability guarantees.
The @var list<Test> annotation declares that $tests is a list of Test objects. In PHP and static analysis terminology, a list is a special kind of array with these properties:
-
Keys are sequential integers starting from 0:
[0, 1, 2, ...] -
No gaps in the sequence: Every integer from
0tocount() - 1must be present - Keys are in ascending order
The list<Test> type annotation provides stronger guarantees than just array<Test> would.
The method fromArray(array $tests) is a named constructor and uses the unpacking operator ... to break down the array into individual arguments.
The named constructor pattern uses static methods with meaningful names to create objects instead of calling the constructor directly. This makes the intention of object creation explicit and enables multiple creation methods with different semantics.
array_is_list() is a PHP built-in function that returns true if an array is a list (see above). Using this function in the named constructor bridges the gap between static analysis and runtime:
- The
@param list<Test>annotation tells static analysis to expect a list - The
assert()call verifies at runtime that the input truly is a list (when assertions are enabled) - If someone bypasses static analysis and passes a non-list array, this runtime assertion catches it
The constructor is private, forcing use of fromArray(). The variadic parameter Test ...$tests:
- Accepts zero or more
Testobjects - PHP automatically packs them into a sequential array (a list!)
- This guarantees
$this->testsis always a validlist<Test>
The nice thing about this is that if I call TestCollection::fromArray($data) and there is even one incorrect element in $data, PHP immediately throws a TypeError. I do not have to write any validation logic. The language does it for me at the entry point.
The impact of the unpacking operator is worth addressing briefly. While using ...$tests does create an argument list from the input array, for normal collection sizes typically encountered in our applications, this overhead is negligible in practice. However, if a system routinely constructs collections containing hundreds of thousands or millions of elements, it is operating in a very different problem space, requiring specialised data structures and careful performance engineering. For most use cases, the clarity and type safety gained by this pattern far outweigh the modest cost of argument unpacking.
The asArray() method returns the internal array with the list<Test> return type annotation, preserving type information for callers. This is important so that we can iterate over the elements of the collection. I will explain how to do this below.
With the getIterator() method, we implement PHP's IteratorAggregate interface. Objects that offer this interface can be iterated using the foreach operator. We delegate the logic for iterating the elements of our TestCollection to the separate TestCollectionIterator object that we create in this method.
Collections, as I present them here, do not have to be readonly and immutable. However, whenever possible, I prefer immutability.
Type safety when iterating
It is not enough to ensure that only objects of type Test enter the collection. We must be equally strict in ensuring that only objects of this type leave the collection.
Many developers would implement the getIterator() method described above with a simple return new ArrayIterator($this->tests). The problem with this is that the current() method of ArrayIterator returns a value of type mixed. As a result, IDEs and static code analysis tools often lose track or need to be supported by annotations in comments such as /** @var Test $item */.
The solution is to use a custom iterator that is specific to our own collection class. Thanks to the covariance of return types in PHP, we can tighten the contract of Iterator (current(): mixed) in our implementation (current(): Test):
<?php declare(strict_types=1); /** * @template-implements Iterator<non-negative-int, Test> */ final class TestCollectionIterator implements Iterator { /** * @var list<Test> */ private readonly array $tests; /** * @var non-negative-int */ private int $position = 0; public function __construct(TestCollection $tests) { $this->tests = $tests->asArray(); } public function rewind(): void { $this->position = 0; } public function valid(): bool { return isset($this->tests[$this->position]); } /** * @return non-negative-int */ public function key(): int { return $this->position; } public function current(): Test { assert(isset($this->tests[$this->position])); return $this->tests[$this->position]; } public function next(): void { $this->position++; } }
@template-implements Iterator<non-negative-int, Test> tells static analysis tools how this object is iterated:
-
Keys will always be
non-negative-int(integers ≥ 0) -
Values will always be
Testobjects
The position tracker $position is typed as non-negative-int, ensuring it can never be negative, which makes sense for array indices in a list. It is initialized to 0.
The constructor takes a TestCollection and extracts its internal array. Type safety is preserved because asArray() returns list<Test>.
The methods required by the Iterator interface are implemented like so:
-
rewind(): Resets position to0 -
valid(): Checks if current position exists in the array -
key(): Returns the current position (annotated asnon-negative-int) -
current(): Returns theTestat the current position (with a runtime assertion for safety) -
next(): Increments position
This explicit iterator for our TestCollection allows us to achieve looping without guessing:
<?php declare(strict_types=1); $tests = TestCollection::fromArray([new Test(/* ... */)]); foreach ($tests as $key => $test) { // ... }
For the code example shown above, the IDE and static code analysis tools know without a doubt that $key always contains a non-negative value of type int and $test always contains an object of type Test.
Community Discussion
The custom iterator implementation shown in this article sparked a constructive discussion about the assumptions underlying the TestCollectionIterator.
Andreas Heigl raised an important point about iterator semantics. Traditionally, iterators allow the internal pointer to move beyond the valid range. In this case, current() returns false and key() returns null, while valid() determines whether the iterator remains within bounds. The presented implementation makes the deliberate and explicit assumption that iterator methods will only be called within the context of foreach loops, where PHP's iteration protocol ensures that current() is never invoked after valid() returns false.
While this assumption may appear bold, particularly in library code where developers cannot control the calling context, it holds true for the vast majority of application-level use cases. Importantly, this design decision is not hidden technical debt, but an intentional trade-off made explicit through comprehensive testing.
The accompanying test suite verifies that the iterator behaves correctly under expected usage patterns, transforming what would otherwise be an implicit assumption into documented, validated behaviour. This approach demonstrates how testing can do more than just catch bugs; it can also codify and communicate the contracts and constraints that define a component's intended use.
I do not want to wait
Arnaud Le Blanc, Derick Rethans, and Larry Garfield discuss generic arrays in their article. They distinguish between:
-
Fluid arrays: The type is determined by the content (
$a = [new A]isarray<A>). As soon as I add something else ($a[] = new B), the type changes "fluidly" toarray<A|B>. -
Fixed arrays: The type is fixed at creation (
array<int>). This is difficult to reconcile with PHP's copy-on-write mechanism.
The article authors see massive hurdles here: variance, invariance, performance. Collection objects, as I have described them in this article, solve this dilemma pragmatically: such a collection is effectively a fixed array.
Why boilerplate does not hurt
Yes, I have to write two classes and 90 lines of code just to manage a list of objects. Formally, that is boilerplate. But honestly, it does not bother me. Especially since only 14 of those 90 lines of code are relevant in terms of the software metric Logical Lines of Code.
If your team prefers, this pattern is easy to generate and you can automate it if you want to. After experimenting with that idea in the form of Shaku, I found that simple copy/paste/adapt was sufficient for me in practice. The real advantage lies not in the 90 lines of code themselves, but in the fact that every collection in the codebase looks and behaves in the same way, greatly improving consistency and maintainability.
And once the code is there, it almost never needs to be changed. It is boring, stable, and does exactly what it is supposed to do. It keeps the rest of my code clean and type-safe.
Revisions
-
:
I have added a more detailed explanation of the example code, motivated by feedback. -
:
I have added a "Community Discussion" info box.