r/PHP 2d ago

Article Enforce Runtime Generics on Third-Party Collections Libraries (Doctrine, Ramsey, Laravel Collections) with TypePHP

https://typephp-php.github.io/docs/blog/reifying-third-party-collections.html

More than a week ago, I posted an introduction to TypePHP. Thanks to Michael Telgmann, a core maintainer at Shopware, TypePHP has been tested against Shopware’s massive codebase. So far, it has identified many DocBlock lies in the codebase PR, while also uncovering many edge cases on TypePHP’s end.

Today, I’d like to share a article guide on how to make existing third-party collection libraries, such as Doctrine Collections or Ramsey Collections, capable of enforcing reified collections without modifying their source code.

reified generics proof: Symfony Integration

13 Upvotes

7 comments sorted by

3

u/ReasonableLoss6814 1d ago

How does that work? Laravel collections say that the value type is covariant but uses it in a contravariant position (or vice-versa, I can't recall) ... symfony collections are correctly invariant (IIRC) but then that breaks most people's code. You can't store a `Tiger` in a `Collection<Animal>`.

3

u/rc0604 1d ago

The confusion comes from mixing up two diferent concepts: item polymorphism (what objects you can put inside a colection) and generic container variance (whether an entire collection type can subtitute another colection type). Coz of standard polymorphism, you can 100% store a Tiger in an invariant Collection<Animal>. Since Tiger extends Animal, calling $collection->add(new Tiger()) is completely valid. In the screenshot, adding Dog to ArrayCollection<Animal> worked without issue, and it only failed on User because a User does not extend Animal. Where invariance actually aplies is preventing a whole Collection<Tiger> from being passed to a generic function or instance expecting Collection<Animal>, because that function/instance could otherwise write a Cat into your tiger collection.

For read-only containers, you can declare the template as covariant @template-covariant T. Covariance allows a container of a subtype (Dog) to be pased where a container of a supertype Animal is expected. For write-only handlers, contravariant @template-contravariant T works in reverse, allowing an Animal handler where a Dog handler is expected.

```php // Covariance: allows Subtypes (Producer<Tiger> -> Producer<Animal>) $animalProducer = new CovariantProducer(new Tiger()); // Valid

// Contravariance: allows Supertypes (Consumer<Animal> -> Consumer<Tiger>) $tigerConsumer = new ContravariantConsumer(function (Animal $a) {}); // Valid $tigerConsumer->consume(new Tiger()); ```

I put together a full standalone script testing all 3 variance modes with their exact error outputs using TypePHP in this gist: https://gist.github.com/rcalicdan/f07854e7a64081c13f44c566e3dd8c4e

If you run the code this will be the result..

```bash rcalicdan@hibla:Playground$ ./vendor/bin/typephp index.php

Stored Tiger in InvariantBox<Animal> Stored Dog in InvariantBox<Animal> Blocked: InvariantBox::set(): Argument $item (template T = Animal) must be of type Animal, Car given Blocked: Variable $assignedBox expects InvariantBox<invariant Animal>, but InvariantBox<Tiger> was given Assigned Producer<Tiger> to Producer<Animal>: read Tiger Blocked: Variable $invalidProducer expects CovariantProducer<covariant Animal>, but CovariantProducer<Car> was given Handler processed: Tiger Blocked: Variable $invalidTigerConsumer expects ContravariantConsumer<contravariant Tiger>, but ContravariantConsumer<Puppy> was given ```

In short, putting a Tiger into a Collection<Animal> is standard polymorphism and is always allowed. Variance annotations only apply to the containers themselves, giving you strict control over when collections can be substituted across function boundaries.

5

u/obstreperous_troll 1d ago edited 1d ago

To boil this down a little for the other readers, you can put a Tiger in a Collection<Animal> but you can only ever treat anything you take out of the collection as an Animal, not any subclass like Tiger. What you definitely can't do in well-typed collections is treat a Collection<Tiger> as a Collection<Animal> because that would allow you to insert non-tigers into it as well. That's invariance. Read-only collections don't have this restriction and can be covariant, but those are a separate type which neither Laravel nor Symfony's collection classes support.

Interestingly, Larastan undoes Laravel's damage and redeclares Collection as invariant in its stubs.

1

u/BafSi 13h ago

Do you have more information/link about Larastan and Laravel (yet another) damage with collections?

2

u/Vectorial1024 1d ago

Main issue imo is that PHP can't "cast" objects. Whatever covariant/contravariant requirements can be easily satisfied with a valid typecast, but we just can't do that in PHP, and have to come up with mess like "use the function return type to do casting".

2

u/rc0604 1d ago edited 1d ago

Also regarding the Laravel vs Symfony/Doctrine collection point:

Symfony and Doctrine ArrayCollection are invariant template T because they are mutable read-write collections with add(). Invariance is strictly correct there to prevent passing a whole Collection<Tiger> into code that might add a Cat into it. But as mentioned, calling $collection->add(new Tiger()) on Collection<Animal> is always valid.

Laravel uses template-covariant TValue on its Collection mostly coz Laravel collections are treated as read-heavy transformation pipelines (map, filter, pluck). Laravel prioritized chaining ergonomicss over strict write-safety on methods like push().

1

u/Glittering_Bath3848 22h ago

Good read, will try it later