DEV Community

Cover image for PHP Generics Already Exist: They're Just Hidden in PHPDoc
Ivan Mykhavko
Ivan Mykhavko

Posted on

PHP Generics Already Exist: They're Just Hidden in PHPDoc

Erased generics RFC and runtime safety concerns

Every Laravel dev has written PHP generics. You just wrote them inside a comment and pretended it didn't count.

/** @return Collection<CartItemDTO> */
Enter fullscreen mode Exit fullscreen mode

That line is a generic type. PHPStan reads it, your IDE reads it, but PHP itself shrugs and ignores it. A new RFC, Bound-Erased Generic Types, wants to turn those comments into real syntax. Let's start with what a generic even is, then look at why PHP has fought this for a decade.

A 30-Second Theory Refresh

A generic is a type with a hole in it. Instead of writing IntBox, StringBox, and UserBox, you write Box<T> once and decide T later. The formal name is parametric polymorphism.

// Without generics: one class per type, copy-paste forever
final class IntBox { public function get(): int { /* ... */ } }

// With generics: one class, T decided at use site
final class Box<T> { public function get(): T { /* ... */ } }
Enter fullscreen mode Exit fullscreen mode

The idea isn't new. Java, C#, and TypeScript all have it. PHP and JS skipped it for years, which is exactly why static analyzers grew up to fill the gap.

There are two ways to ship generics. Reified keeps the type at runtime, so Box<int> and Box<string> stay distinct (C#). Erased uses it only for static analysis and throws it away before execution (Java, TypeScript). This RFC picks erasure. Hold that thought.

Why It's So Hard to Add to PHP

Generics have been on the PHP internals agenda since January 2014. Multiple RFCs, multiple implementations, none merged. Why?

  • Runtime type checks are expensive. Reified generics mean PHP must store and verify type arguments on every instance. Nikita Popov built a prototype and hit superlinear type-checking cost and heavy per-instance memory. PHP is request-per-process, so you pay that on every request.
  • No shared spec. PHPStan, Psalm, Mago, and PhpStorm have spent years guessing at the same @template syntax, each a little differently. There's no official standard to follow, so an edge case that works in one tool can quietly break in another.
  • A decade of stalls. The 2016 reified RFC sat in draft for ten years. The 2024 reified continuation stalled on cross-file inference. Each attempt paid the syntax cost and shipped no syntax.

The Solution: Erase, Don't Reify

The RFC, by Seifeddine Gmati (author of the Mago analyzer), adds native syntax: class Box<T>, bounds <T : Animal>, defaults <K = string>, variance <+T> / <-T>, and an optional turbofish ::<…>.

The key word is bound-erased. At runtime Box<int> and Box<string> are the same class. The type argument is erased down to its bound (or mixed). Checking happens statically, like Java or TypeScript. Reified was rejected on purpose: as the RFC puts it, "we don't need runtime type checks." Generics are a static-analysis tool, not a runtime one.

So your comment becomes the signature, and old code keeps working. The turbofish is optional everywhere:

// Today: the type parameter lives in a doc block
/**
 * @template T
 */
interface Repository
{
    /** @return Collection<T> */
    public function all(): Collection;
}

// With the RFC: the same intent, but the language reads it
interface Repository<T>
{
    public function all(): Collection<T>;
}
Enter fullscreen mode Exit fullscreen mode

It's Not New: Just Look at Your Own Code

I grepped a Laravel 10 project I work on (PHP 8.1, Larastan level 5). The result: 143 generic-PHPDoc annotations and 0 of my own @template. So the project consumes generics everywhere (mostly Collection<…>) but never declares them. Here's a real action from it:

final class CartIndexAction implements Actionable
{
    /** @return Collection<CartItemDTO> */
    public function handle(CartItemParamDTO $param, int $userId): Collection
    {
        return $this->cartItemService->getForUser($userId, $param->itemUuids)->values();
    }
}
Enter fullscreen mode Exit fullscreen mode

The native return type is just Collection. The useful part, CartItemDTO, lives in a comment. Drop it and autocomplete dies and Larastan stops catching wrong-type bugs. It's worse in repositories, where you babysit the analyzer by hand:

public function getUserCarts(int $userId): EloquentCollection
{
    /** @var EloquentCollection<Cart> $carts */
    $carts = Cart::query()->where('user_id', $userId)->get();

    return $carts;
}
Enter fullscreen mode Exit fullscreen mode

That /** @var */ exists only to feed the analyzer. And it's not just my code: Laravel itself ships generic annotations across its core classes and IDE-helper stubs. Every Collection and query Builder you touch is already generic in the doc blocks. The RFC authors counted 200k+ files on GitHub using @template. Adopting this RFC doesn't introduce generics. They've been here for a decade. It formalizes what's already there.

I Want This in 2026

Heads up: the RFC is still Under Discussion (v0.22), with an open php-src PR that works today. The blocker isn't the code. It's a small group on internals who don't love the erased approach.

We've babysat doc blocks and worked around analyzer quirks for years because generics are worth it. The implementation is ready, and the proposed syntax is already familiar to PHP developers using PHPStan and Psalm. PHP developers already write generics every day. The only question left is whether they keep living in comments, or finally become part of the language. I know which one I want in 2026.

TL;DR

  • A generic is a type with a hole: Box<T> instead of one class per type. Old idea (Java, C#, TS).
  • PHP generics already exist in @template / <…> PHPDoc your analyzer reads. 200k+ files on GitHub use them.
  • Adding them natively is hard: reified runtime checks are expensive, and ~6 analyzers disagree with no shared spec. On the agenda since 2014.
  • The RFC picks bound erasure: checked statically, erased at runtime, Box<int>Box<string>, zero runtime cost.
  • Existing code keeps working; the turbofish ::<…> remains optional.

💡 Watch Brent Roose's video walkthrough and read the RFC before the vote.

Author's Note

Thanks for sticking around!
Find me on dev.to, linkedin, or you can check out my work on github.

Laravel, after the happy path.

Top comments (5)

Collapse
 
xwero profile image
david duymelinck

Great introduction for the people that don't know generics!

My 2 cents, it is an accident waiting to happen without the runtime check.
I have seen so many errors from not checking the type in Typescript when using generics, that it is not funny.

I also seen that generics encourage generic (pardon the pun) names. Naming things is already hard enough.

Sure there are functions that could be deduplicated with generics, but there are other options like static creation factory methods.

class Box {
   private __construct(private string $content) {}

   public function __invoke() 
   {
       // The meat of the function
       return $this->content;
   }

   public static function fromString(string $content): self
   {
        return new self($content)
   }

   public static function fromInt(int $content): self
   {
       return new self((string) $content); 
   }
}
Enter fullscreen mode Exit fullscreen mode

OK it is not a function any more, but the main objective is achieved.

I see the community adopted DocBlock generics for array shapes. It is even in PHPStorm now.
Maybe I am a lonely voice calling into the wind for being wary about that evolution.

Collapse
 
tegos profile image
Ivan Mykhavko

Cool, thanks 😄

Runtime gap is real, erased means PHP trusts you at the door. Static tool, not a bodyguard.

But we already write @template TItem in comments today. The RFC just gives it a real home...

Your Box::fromString / fromInt is clean for a few known types. Generics win on open containers, eg a repo for any model, but factories need a method per type.

And better one real syntax than a comment dialect every analyzer reads differently.
But honestly I dont bet that this rfc will be accepted.

Collapse
 
xwero profile image
david duymelinck

Why bother with syntax just because there are differences in how static analysis packages parse generics?
Wouldn't it be better to have a PSR instead of syntax?

Generics win on open containers, eg a repo for any model

What is the added value of an open container?

For the collection examples in the post, why not use custom collections.

The main thing I want to communicate is when the type is important enough that it needs to go error free through a static analysis wouldn't it be better that it does it at runtime too?

Thread Thread
 
tegos profile image
Ivan Mykhavko

Good questions, you make me think :)

PSR vs syntax: PSR is just a doc, every tool still parses comments on its own and drifts. The engine and IDE only fully agree when it is real syntax
they share. That is the gap a PSR cannot close.
But if rfc want be accepted, PSR is also minimum option.

Open container value: write one Collection or Repository, get type-safe reads for every model without a class per type. Custom collections work
great, but you hand-roll UserCollection, OrderCollection, ... forever. Generics are that same idea minus the boilerplate.

Runtime point: that is the honest tension and you are right. If a type matters that much, runtime check would be nicer. But reified generics cost real
performance, and PHP never accept it. Erased is the compromise that can actually pass: not perfect, but better than living in comments.

Still not betting it lands though 😄

Thread Thread
 
xwero profile image
david duymelinck • Edited

PSR is just a doc

True PSR's are documented rules. But they are the base for interoperability between different systems. That is why I think a PSR is a better a fit than syntax.

get type-safe reads for every model without a class per type

Yes they get type-safe reads for cases like Collection<Cart>, but there is no type safety when it is Collection<T>.
If the goal is type safety make it enforceable at runtime.

My comments are not about the RFC making it or not. But about the consequences the changes would bring with it.