The image shows several interlocking gears in white and yellow on a dark board surface. A hand is adding to or highlighting one of the yellow gears. This hand can be interpreted as a symbol of the developer, who deliberately inserts new, type-safe components into an existing system without disrupting the mechanism.

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:

  1. Type annotations for static code analysis with PHPStan or another tool
  2. 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 foreach loop, 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);
    }
}

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.

The constructor __construct(Test ...$tests) accepts any number of values of type 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 allows the collection to be used as an array. 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++;
    }
}

This explicit iterator 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.

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] is array<A>). As soon as I add something else ($a[] = new B), the type changes "fluidly" to array<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.

I have over 35 years' experience developing software, including almost 30 years working with PHP. I have also been developing PHPUnit for over 25 years. The knowledge I have gained during this time is reflected in my articles, but this is just the tip of the iceberg.

If you and your team would like to benefit from my experience through consulting and coaching, I look forward to hearing from you. Send me a message.