[PHP-DEV] [RFC] [Discussion] Bound-Erased Generic Types

On Mon, May 11, 2026, at 4:17 PM, Seifeddine Gmati wrote:

Hi Larry,

Thank you for your review.

As you note, this RFC is a little more than erased generics; it does provide a little validation, and reflection support (in addition to a standardized syntax that's much more understandable than the current de facto standard docblock syntax). That's not nothing, and is a marginal improvement over the status quo. The question is whether that is enough of a benefit, and what future improvements it makes easier/harder.

I'd argue this RFC makes future improvements strictly easier, not
harder. see PHP: rfc:bound_erased_generic_types.
The work this RFC does (syntax, parsing, compile time enforcement,
reflection) is baseline infrastructure that any generics
implementation in PHP will need regardless of approach. Landing it now
gives us a foundation to build on.

Technically, anyone using Symfony or Laravel is "using generics" in that Symfony and Laravel have generic doc-types on their code. That doesn't imply that everyone building a site with Symfony or Laravel is regularly running PHPStan to verify those, or adding their own doc-types to match it.

I disagree here. Someone using a Laravel collection class without
engaging with its `@template` annotations isn't "using generics",
they're consuming a typed API and ignoring the type parameters. That
pattern continues unchanged under this RFC: a user can call
`$collection->map(...)` without ever writing or reading a generic type
argument. The only place behavior changes is inheritance: someone who
extends or implements a generic class is now required to provide type
arguments (e.g. `class MyCollection extends Collection<int>`), and
*that* get enforced at both compile time and runtime, because concrete
type arguments get substituted into method signatures.

Where this becomes a land-mine is less the production deploys today, but that future improvements become BC breaks. [...] My fear is that people who don't use SA tools will write code on top of someone else's generic code, not care that their types are buggy, not notice, and then we start enforcing it in the future and their code breaks.

This is the right concern to raise, and I think it's addressable
without needing the "your types are wrong, not covered by BC" escape
hatch you propose below.

The principle: as long as we don't change the semantics of existing
syntax, no future improvement introduces a BC break. Reified generics,
for example, can be added later as opt-in, just like how HackLang did
it (Migration Features for Reified Generics | Hack & HHVM Documentation).
A class or function would need to declare `#[ReifiedGenerics]` (or
similar) to opt into reified semantics; everything else continues to
behave exactly as the bound-erased model specifies. Library and
framework authors then choose the strictness-vs-performance tradeoff
that fits their use case, and existing code never breaks because the
default behavior never changes.

So the "BC only for sloppy code" risk you describe doesn't
materialize, sloppy code today stays sloppy tomorrow at exactly the
same level.

My concern is something like this:

function foo<T>(T $val): bool {}

// With this RFC, this will compile and run, and maybe error inside foo() in oddball ways, or not.
foo::<int>('beep');

Because the type param isn't enforced. So someone is going to write code that bad, guaranteed. (This is PHP, after all.)

Now fast forward a few years, and we figure out a way to performantly enforce that check at runtime and turn that function call into a call-site TypeError. I would consider that a good improvement to the language. However, it would also mean that the previous mismatched line would now generate an error where it didn't before. And the author is going to get up in arms about how "PHP is breaking my code and destroying the language why can't they respect BC" and so on and so on, because we've seen that movie several times now.

But what I would absolutely not want to see is someone arguing that "well we can't start enforcing that type at runtime, because someone *might* have stupid code." Or, even worse, #[ParamTypeMismatchesThatsOk] as an attribute on the function to opt-out of enforcing it, the way we did for return types on magic methods. I would consider both of those to be Very Bad(tm) outcomes.

So the question for me is how do we set it up, both technically and politically, so that if/when we figure out how to enforce that we can do so without creating another "boo hoo you broke my code" round of blog posts, as those are quite bad for PHP's image.

Another option, if we want to take the stance that SA is The Solution(tm) to generics, is to ship an actual first-party SA tool with PHP.

I want to push back on this strongly. speaking as Mago's author for a moment.

The existing SA tools (PHPStan, Psalm, Mago) aren't just type
checkers. Type checking is part of what they do, but they also handle
a much larger surface: types PHP itself has no notion of
(`positive-int`, `non-empty-lowercase-string`, `class-string<Foo>`,
`list{1, 3, "hello"}`, and 100s more, see
Elements: the indivisible types - The Suffete Book),
plus code quality rules, security analysis, dead code detection, and
so on. A first-party tool would need to compete on that whole surface
to be useful, anything narrower would disappoint the audience that
actually wants SA.

That leaves two options, neither good:

1. A weaker tool than what exists. This doesn't serve the audience
that wants real SA, and users will rightly be frustrated that PHP
shipped something less capable than third-party alternatives.

2. A tool competitive with existing ones. This is probably a year or
two long C project requiring a dedicated team, on top of the PHP core
team's existing scope. As someone who built a full toolchain in Rust
from scratsh following Psalm/Hakana's footsteps, I can tell you this
is substantially harder than it looks, and it would be *considerably*
harder in our case than it was for me, for two reasons:

   2.1. Mago is written in Rust, which is a more productive language
for this kind of tool and has the ecosystem to match. Building the
equivalent in C, against PHP's existing engine constraints, means a
much higher cost per feature, per bug fix, per refactor.

   2.2. Mago isn't part of PHP. If we get variance wrong, we ship a
fix the same day. sometimes twice a day, sometimes once a week. We
aren't bound by the php-src release cycle, and we iterate as soon as
we have a fix. A first-party SA tool inherits PHP's release cadence:
fixed-cadence releases, feature freeze windows, stability guarantees.
For a tool whose value depends on keeping up with framework patterns,
library conventions, and emerging idioms, that release shape is wrong.

Beyond the engineering cost, there's a parser problem: PHP's current
parser doesn't produce the full CST that an SA tool needs. You'd need
a new parser, a static name resolver, a static reflection system that
doesn't execute files to extract type information, and so on, a
substantial reimplementation of analysis infrastructure in C, which
the community already has high-quality versions of in Rust and PHP.

In short: shipping a first-party SA tool would be a large, probably
multi-year, investment for a result that's almost certainly worse than
the tools the community has already built. I don't think it's a good
use of PHP's resources, and I'd argue strongly against it.

In general, several of those issues are entirely self-created, and PHP can easily resolve them.

1. Nothing says we can't provide a first-party SA tool that's written in Rust instead of C.

2. Nothing says a first-party SA tool must follow the exact same cadence as the engine. Making improvements to it at the same time as a .z release of the engine is completely reasonable; it would be purely an administrative decision to do that or not.

First-party doesn't have to mean "in the php-src C code directories." Though leveraging some parts of those as externs could certainly make sense.

(Joking: Should we just ship Mago with PHP? :slight_smile: )

I agree with Bob that the +/- syntax is very confusing. I would much rather use in/out, as seen in Kotlin and C#, which are both vastly more self-documenting and match what some of our peer languages are doing.

No strong preference here. I went with `+`/`-` because that's what
HackLang uses and it's familiar to me, but `in`/`out` is fine if
that's the consensus. Happy to switch if enough people prefer the
keyword form.

Kotlin and C# have several orders of magnitude more users, so the "familiarity" argument goes firmly in that direction.

An interesting quirk I found in my previous research is that languages that use : for inheritance use : for bounding generics. Languages that use "extends" for inheritance also use "extends" for bounding generics. PHP uses "extends", so that pattern would suggest we use "class Box<T extends FooBar>" rather than :.

I'd disagree on this one. `class A<T extends C> extends B` reads
awkwardly to me, especially when the bound is scalar or a union eg.,
`class A<T extends int|string> extends B`, is rough to parse visually.
The colon form keeps the type-parameter bound visually distinct from
the class's own extension relationship, which I think is the more
important consistency to optimize for.

I don't disagree with you here. As I said, it's more that I want to bring it up and make sure it's an explicit decision to use a different signifier than everyone else.

I fully expect "turbofish" to result in all kinds of slide shenanigans at conferences. At least on my slides. :slight_smile:

It hasn't been a problem in Rust, and I don't think it will be in PHP
either. (Looking forward to the slides though)

At what point did I say slide shenanigans are a problem? :slight_smile:

I'd be completely OK with lowering the argument cap from 127, too.

that makes sense in principle, but assuming you mean the
actual-parameter cap rather than the type-parameter cap, that's a
separate RFC and would itself be a BC break. Worth doing eventually,
just not as part of this one.

No no, I meant the type argument cap.

function foo<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>(/* */) {}

That's 16 type arguments, an order of magnitude less than the cap, and I'll already say any such code should be rejected on sight as bonkers. So reserving more than one bit for future flags seems completely fine to me, and perhaps advisable.

Why is ReflectionGenericVariance backed? I don't see what the ints add.

It's backed because the values match what the engine uses internally.
no strong preference though, i can switch it to a unit enum if that
reads better.

I'd probably omit it, but if it's kept, the RFC should at least include that explanation.

OK, having read the full RFC, it seems to be sort of "mostly-erased" generics. There is notably more enforcement than the term "erased" would imply. [...] It looks like, in practice, basically everything on the declaration side is enforced; it's just the call side that is unenforced. Is that an approximately accurate summary?

Approximately, yes, declaration-side enforcement is substantial,
call-side type arguments are erased. The proposal is closer to Java's
model than Python's, but I want to keep the "erased" terminology
because the defining property, type arguments not available at
runtime, is what distinguishes this from reified generics.
"Bound-erased" is the precise term: bounds are enforced where they can
be (declaration sites, substituted method signatures), type arguments
themselves are erased at use sites. That said, your point about
expectations is fair, I'll consider moving the enforcement section
higher in the RFC to set those expectations earlier.

And explicitly calling out something like "despite the name, this is mostly-enforced generics." Or something like that, which seems more accurate.

Would this be enforceable?

class Collection<T> {
  public function add(T $val): void {}
}

$c = new Collection<int>();

$c->add('string'); // Error, or would this be allowed at runtime?

My gut sense is that is the most common place where it would come up; function/method generics are going to be a lot less common than class generics, so that would be the place to ensure we get as much enforcement as we can.

If I understand correctly, this RFC would allow for foo(Collection<int> $c) {}, but wouldn't actually enforce it at runtime. Within the function, `instanceof Collection` would still work, but there's no `instanceof Collection<int>` alternative. Am I reading that correctly?

yes. that's exactly the erasure behavior. the type argument isn't
available at runtime, so `instanceof Collection<int>` is the same as
`instanceof Collection`. If you pass a `Collection<string>` to a
function expecting `Collection<int>`, runtime accepts it because both
are `Collection` at runtime; the mismatch is caught by SA, not by the
engine.

I think at this point I am still skeptical, but warming to it, and could be convinced. But more convincing is needed. And lunch, which I think I need after reading all of this. :slight_smile:

Hopefully the above moves you a bit further along. Please let me know
if you have more questions or concerns.

Cheers,
Seifeddine.

Two additional questions:

- With reflection, would it be possible to tell what a given object was instantiated with, or only the class? new ReflectionObject($listOfInts)->getTypeParam() => ReflectionType(int)? Or is that also the erased part?

- I assume that dynamically specifying the type parameter is also right-out, yes?

function make(string $className, int $count) {
  $c = new Foo<$className>();
  foreach ($count as $i) {
    $c->add(new $className());
  }
  return $c;
}

--Larry Garfield

Now fast forward a few years, and we figure out a way to performantly enforce that check at runtime and turn that function call into a call-site TypeError. I would consider that a good improvement to the language. However, it would also mean that the previous mismatched line would now generate an error where it didn't before. And the author is going to get up in arms about how "PHP is breaking my code and destroying the language why can't they respect BC" and so on and so on, because we've seen that movie several times now.

This depends on how reified generics are implemented, specifically if
it's the way you suggest. I.e, all erased generics become reified,
then this is a problem. However, if we make reified generics an opt-in
feature, this is not a concern, because the code will keep working as
is, and would only break if the author of `foo` enables reified
generics for it.

Personally, if i had the option of full-reified generics from day one,
or bound-eraser + opt-in reification, the latter makes more sense.
Because I don't want to pay for the performance penalty that will come
with reified generics, when I know that all my code is type-safe.

Quoting Matt Brown: "I have now been writing Hack (Facebook fork of
PHP) full-time for almost five years — alongside hundreds of other
backend engineers at Slack — and it’s obvious to everyone around me
that erased generics are a good idea."

In general, several of those issues are entirely self-created, and PHP can easily resolve them.

1. Nothing says we can't provide a first-party SA tool that's written in Rust instead of C.

2. Nothing says a first-party SA tool must follow the exact same cadence as the engine. Making improvements to it at the same time as a .z release of the engine is completely reasonable; it would be purely an administrative decision to do that or not.

First-party doesn't have to mean "in the php-src C code directories." Though leveraging some parts of those as externs could certainly make sense.

1. If it's written in Rust, C++, Zig, whatever, it might make the work
easier, but it won't make it instant.
2. True, but again, this needs a maintenance team, a spec team, its
own documentation, and more.

(Joking: Should we just ship Mago with PHP? :slight_smile: )

Mago could help if such an SA were written in Rust, because it can
provide the parser, name resolver, reflections, semantics analyzer,
and a few other crates. However, the analyzer can't be used as is,
because it does much more than just "check if generics are being used
correctly".

However, this would open a can of worms: Should this analyzer support
watch mode? Should it perform incremental analysis? What about the
language server? Should it offer IDE/editor integration? and a million
other things.

( Also note: PHP *can't* actually use the Mago parser and get away
with it, because in Mago, we made a decision a long time ago not to
support non-utf-8 code. PHP must stay true to the engine, so it must
re-write its own parser ).

Kotlin and C# have several orders of magnitude more users, so the "familiarity" argument goes firmly in that direction.

We will see, this small change can be made later, before the vote, or
perhaps require a secondary vote.

At what point did I say slide shenanigans are a problem? :slight_smile:

Nvm, read it wrong :slight_smile:

No no, I meant the type argument cap.

function foo<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>(/* */) {}

That's 16 type arguments, an order of magnitude less than the cap, and I'll already say any such code should be rejected on sight as bonkers. So reserving more than one bit for future flags seems completely fine to me, and perhaps advisable.

Ah, I agree. Already changed the cap to 127.

I'd probably omit it, but if it's kept, the RFC should at least include that explanation.

I will omit it.

And explicitly calling out something like "despite the name, this is mostly-enforced generics." Or something like that, which seems more accurate.

Would this be enforceable?

class Collection<T> {
  public function add(T $val): void {}
}

$c = new Collection<int>();

$c->add('string'); // Error, or would this be allowed at runtime?

No, hence the name "bound erased." In this example, `T` has no bound,
therefore, it is the same as if it were bound to `mixed`, so this
won't error.

However, the following applies:

class Collection<T> {
  public function add(T $val): void {}
}

class UserCollection extends Collection<User> {}

$c = new UserCollection();
$c->add(new User()); // ok
$c->add("hello"); // TypeError

because `T` gets substituted with `User` at compile time.

- With reflection, would it be possible to tell what a given object was instantiated with, or only the class? new ReflectionObject($listOfInts)->getTypeParam() => ReflectionType(int)? Or is that also the erased part?

No, that's erased. This is entering reified generics territory. and
causes associated performance issues. Either we add an extra pointer
field to `zend_object` ( +8 bytes on every PHP object, regardless of
whether it uses generics or not ), or we keep a side table keyed by
object handle. The latter moves the cost from storage to lookup (every
read incurs a hash table hit, and the table must be torn down on
`__destruct` ). Neither is free.

Beyond storage, the bindings themselves aren't cheap to carry either:
each `zend_type` is 16 bytes plus any list / named-with-args payload
it points to. Therefore, something like `Map<string, Box<User>>`
involves multiple type structures per instance, all of which must be
kept alive, reference-counted, and traversed at instanceof /
reflection time.

And once the runtime can read the binding back, the pressure to
actually use it follows immediately: $obj instanceof Box<string>
becoming truthful, parameter checks tightening from the bound to the
substituted type, etc. That's the full reified runtime, which is a
separate RFC (and one we explicitly punt on in "Why bound erasure").

- I assume that dynamically specifying the type parameter is also right-out, yes?

Yep, not supported, and I don't see it being useful at all really. the
way to type that function properly would be something like this:

function make<T: object>(int $count) {
  $c = new Foo::<T>();
  foreach ($count as $i) {
    $c->add(new T());
  }

  return $c;
}

Note: This requires reified generics, specifically, the `new T` part.

On 12.5.2026 00:07:19, Rowan Tommins [IMSoP] wrote:

On 11 May 2026 22:17:22 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:

The principle: as long as we don't change the semantics of existing
syntax, no future improvement introduces a BC break. Reified generics,
for example, can be added later as opt-in, just like how HackLang did
it (Migration Features for Reified Generics | Hack & HHVM Documentation).
A class or function would need to declare `#[ReifiedGenerics]` (or
similar) to opt into reified semantics; everything else continues to
behave exactly as the bound-erased model specifies. Library and
framework authors then choose the strictness-vs-performance tradeoff
that fits their use case, and existing code never breaks because the
default behavior never changes.

This feels like a cop-out to me. Imagine we had done the same for visibility keywords: "private" was a reserved word, but the engine didn't enforce it, it was just there for static analysers; then later, we added an opt-in attribute #[EnforceVisibility]. Either everyone would use it and whinge about it not being the default; or nobody would use it, and whinge about the language having a broken visibility system.

Or imagine if we implemented scalar type declarations, but couldn't decide whether to make them fully-static or auto-coercing, so we implemented a switch that had to be set at the call-site, and was widely misunderstood ... oh, wait, we did that one...

I totally agree that an attribute meaningfully altering runtime behaviour (unlike the current ones which are mostly simple checks) is bonkers.

If we ever switch to reified generics (i.e. particularly on runtime values), we need to seriously enforce it. Everywhere.

This is - yes - a BC break. But should only be a break for code which does already forbidden things. We can document very clearly that non-enforcement is not an excuse for not following the type checks. And just switch. We could also opt for having a deprecation phase where mismatching types are allowed, but emit E_DEPRECATED. Maybe with an INI (to be removed a couple versions later). At least library code is generally to much higher standards than application code anyway.

Not sure if it's the best way, but surely solutions can be found.

I don't know if this is necessarily a blocker. It's not optimal, but maybe okay.

Bob

On 12 May 2026 00:51:08 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:

Quoting Matt Brown: "I have now been writing Hack (Facebook fork of
PHP) full-time for almost five years — alongside hundreds of other
backend engineers at Slack — and it’s obvious to everyone around me
that erased generics are a good idea."

Just to bang this drum one more time, because I haven't seen you acknowledge it: Hack ships with a tool to statically enforce generic types before erasing them.

<https://docs.hhvm.com/hack/getting-started/tools/&gt;

hh_client: this is the command line interface for Hack's static analysis; it is needed to verify that a project is valid Hack, and is used to find errors in your programs

When people talk about their experience with erased generics in Hack, they're talking about their experience of universal use of that tool.

I don't think you can apply that experience to a situation where the majority of users of the language have no such tool.

Rowan Tommins
[IMSoP]

Hi Rowan,

When people talk about their experience with erased generics in Hack, they're talking about their experience of universal use of that tool.

I don't think you can apply that experience to a situation where the majority of users of the language have no such tool.

`hh_client` isn't structurally different from PHPStan, Psalm, or Mago.
You can run `hhvm bad_file.hack` without ever invoking it, and Hack
has third-party static-analysis too ( Hakana, for example:
GitHub - slackhq/hakana: Another typechecker for Hack, built by Slack · GitHub).

The only difference is that hh_client ships in the same tarball as the
runtime instead of in a Composer package.

The more important point is who's actually in the population the Hack
experience describes. Matt's quote is about Slack engineers, and Slack
adopted Hack because they wanted static analysis. The people whose
experience he's describing are those who chose to engage with the
tool, exactly the same population as PHP developers who choose to run
PHPStan or Psalm or Mago or Phan today. Bundling changes where the
tool originates doesn't change who reaches for it.

The audience for native generics is the same audience that already
uses docblock generics, which is the same audience that already runs
SA. That audience exists in PHP today, voluntarily, without bundling.
Their experience of erased generics in PHP will match Slack's
experience of erased generics in Hack, because they're the same kind
of user with the same tooling discipline.

Cheers,
Seifeddine.

Hi Bob,

I totally agree that an attribute meaningfully altering runtime
behaviour (unlike the current ones which are mostly simple checks) is
bonkers.

The "attribute changing runtime behavior is bonkers" concern is fair,
and the principle of opt-in doesn't require the attribute spelling
specifically. A keyword (class A<reified T>), or a separate
declaration form all carry the same semantic without putting
runtime-altering metadata in the attribute system. The spelling is
open.

We could also opt for having a deprecation phase where
mismatching types are allowed, but emit E_DEPRECATED.

That path could be under consideration, but I think the opt-in path is
structurally better, for two reasons beyond BC.

First, reification isn't free even when it's correct. The library
author who SA-verifies their generic types and ships clean code pays a
runtime cost for the application developer who didn't run SA.
Universal reification transfers cost from sloppy code to careful code,
which is the wrong direction.

Second, the Hack designers had this exact choice (their codebase is
internal, they could have flipped semantics on themselves) and chose
opt-in. They went erased-by-default with reify as an opt-in keyword
(Reified Generics | Hack & HHVM Documentation). The
reason is the same cost-transfer concern: most generic code at
Facebook or Slack scale is type-safe via SA, and forcing it to incur
reification costs would slow the whole codebase for the benefit of a
small percentage of code that genuinely needs runtime checking.

All that said: I appreciate you flagging this isn't a blocker. The
opt-in path is what I think the right design is; if reified generics
ever ship, the spelling debate happens in that RFC, not this one

Cheers,
Seifeddine.

On 12 May 2026 13:01:44 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:

The audience for native generics is the same audience that already
uses docblock generics, which is the same audience that already runs
SA.

This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption.

As soon as PHP ships native syntax for generics, an entirely new set of users will hear about it and try to use it. Their experience is going to be shaped by what *PHP itself* does with that new syntax.

I would also argue that the flipside applies: if the audience for the feature really is existing users of third-party SA tools, then it shouldn't be shipped as part of php-src. The language should only provide the building blocks for those tools to work with - and attributes seem perfectly suited here.

It's not the job of this list to create standards for what attributes third-party tools support.

Rowan Tommins
[IMSoP]

On May 12, 2026, at 3:07 pm, Seifeddine Gmati azjezz@carthage.software wrote:

Hello Internals,

I’d like to start the discussion on a new RFC adding bound-erased
generics types to PHP.

Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.

Thanks,
Seifeddine.

I think this is a good proposal and mirrors the various Python PEPs that added erased type hints in Python 3.

Unlike TypeScript or Hack, Python has no official typechecker — though MyPy is a de facto standard, but it doesn’t ship alongside Python.

Those PEPs have spawned a healthy ecosystem of typecheckers (MyPy, Pyright, Pyre/Pyrefly, Pytype, ty, Zuban). Those typecheckers sometimes take different approaches to the same code (e.g. https://pyrefly.org/blog/container-inference-comparison/) but that seems healthy to me.

I’ve been writing Hack for almost five years, and while it’s true that Hack ships with its own typechecker, I’ve also been able to build a new typechecker for Hack (only reusing the parser) in the span of about 6 months. When you don’t have to worry about docblock parsing a lot of things become quite a bit easier.

This proposal would target a future version of PHP and would realistically only start to seep into code in 2027. I’m very confident that the average person writing PHP code in 2030 will prefer this proposal to using docblock types.

Best wishes,

Matt

On 10. 5. 2026 at 21:02:32, Seifeddine Gmati azjezz@carthage.software wrote:

Hello Internals,

I’d like to start the discussion on a new RFC adding bound-erased
generics types to PHP.

Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.

Thanks,
Seifeddine.

Hi, PHPStan creator and maintainer here. I like this RFC, especially the depth at which it explains why a substantial portion of PHP developers would welcome this addition to the language.

Some people here and on other channels voiced their concerns about types not being enforced at runtime. My view is that this RFC is for an audience that has been experiencing this every day already while developing in PHP for many years. Because the PHP type system is currently insufficient in expressing types, we use generics in PHPDocs for this job. And they don’t mean anything for the runtime. But we make sure to run static analysers to check our code.

This RFC will make the experience of developing in PHP nicer for this audience. I’d compare it to Constructor Property Promotion RFC which reduced the need to mention the same property name in code from 4 to 1.

Today if we want to accept a generic object in a parameter, we have to write it like this:

/**

  • @param Collection $collection

*/
function doFoo(Collection $collection): void
{
}

So the parameter name and the class name have to be repeated.

In this RFC the code above instead becomes this:

function doFoo(Collection $collection): void
{
}

Without making things worse for anyone. But making it better for the audience who has been using generics with no runtime enforcement for many years. Which version would you rather write? Prior art in other languages is the proof this makes sense.

Let’s say this RFC passes and is released in the next PHP version. If I don’t use static analysers today and a library I depend on introduces native generics, nothing changes for me. Same way I violated @template PHPDocs before, now I’m violating native generics instead.

If someone uses @template PHPDocs today in their own code, you can assume they almost certainly also runs a static analyser. No one is forcing anyone to adopt all language features. Same way the match expression isn’t for everyone, bound-erased generic types don’t have to be for everyone.

But there are many PHP developers who would welcome this and have been beating the drum for many years. The PHP documentation about this feature could mention the compromises needed to deliver this anticipated feature. And if someone misuses that (introduces generics in their code without checking with static analyser), well, that’s a bug they’ve introduced — the same class of bug already possible today with @template.

Ondřej Mirtes

On Sun, 10 May 2026 at 21:04, Seifeddine Gmati azjezz@carthage.software wrote:

Hello Internals,

I’d like to start the discussion on a new RFC adding bound-erased
generics types to PHP.

Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.

Thanks,
Seifeddine.

Seifeddine,

Many thanks for such a detailed and interesting proposal. A couple of suggestions for the RFC:

  • It would be great to have some mention of the performance cost of this feature, both for code which does not use generics and for code that does use them
  • It would be great to be able to see how the userland samples from Laravel, Doctrine, etc… would look when using this new syntax, maybe you could add a new section with this

Cheers

Carlos

On Tue, May 12, 2026 at 5:20 PM Ondřej Mirtes <ondrej@mirtes.cz> wrote:

On 10. 5. 2026 at 21:02:32, Seifeddine Gmati azjezz@carthage.software wrote:

Hello Internals,

I’d like to start the discussion on a new RFC adding bound-erased
generics types to PHP.

Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.

Thanks,
Seifeddine.

Hi, PHPStan creator and maintainer here. I like this RFC, especially the depth at which it explains why a substantial portion of PHP developers would welcome this addition to the language.

Some people here and on other channels voiced their concerns about types not being enforced at runtime. My view is that this RFC is for an audience that has been experiencing this every day already while developing in PHP for many years. Because the PHP type system is currently insufficient in expressing types, we use generics in PHPDocs for this job. And they don’t mean anything for the runtime. But we make sure to run static analysers to check our code.

This RFC will make the experience of developing in PHP nicer for this audience. I’d compare it to Constructor Property Promotion RFC which reduced the need to mention the same property name in code from 4 to 1.

Today if we want to accept a generic object in a parameter, we have to write it like this:

/**

  • @param Collection $collection

*/
function doFoo(Collection $collection): void
{
}

So the parameter name and the class name have to be repeated.

In this RFC the code above instead becomes this:

function doFoo(Collection $collection): void
{
}

I think my biggest reservation which I’m sure is the same for plenty of others who’ve read the propsoal is that I can define doFoo there and yet still do

$barCollection = new Collection($barList); // Collection
doFoo($barCollection);

And the only way that will be caught is by static analysis.

Erased generics in Java are fine, because there is a mandatory compilation process that will check and enforce the types. Erased generics in Python is fine, because there is no type checking at all and interpreting the standard syntax for type information is explicitly left to community tooling.

PHP is in a weird middle ground. It has no mandatory static check prior to execution but it does do runtime type checking, everywhere else type declarations are used. But not here, not all the time, if this RFC passes. The very presence of generic types in the language syntax will suggest to users (particularly those who aren’t already using annotations, Psalm, PHPStan, etc.) that the syntax means something, more than effectively an inline comment, in a way that is not true of Python and I think Rowan’s got a solid point about how that reach impacts more than “the people who use SA tools today.” But even more confusingly, these type declarations will be partially used in inheritance and reflection. So we can reflect the pre-erasure types, but can’t conduct an instanceof check. We can’t violate bound generic types via inheritance without a TypeError, but we can otherwise invoke a function with a violating type.

So it looks like PHP can enforce or at least meaningfully understand Collection as a runtime type. But the runtime check is still just Collection. The syntax moves generics out of comments and third party tooling while the validation model will still largely remain in that world.

I want to want this, I want this syntax to be part of PHP in a very real way, but given how much members of the PHP community have been beating the drum for generics for years, is it right to deliver a chimera version of it as the long term solution?

It’s not me who needs to be persuaded in either direction, just food for thought. I like it and I don’t at the same time.

Without making things worse for anyone. But making it better for the audience who has been using generics with no runtime enforcement for many years. Which version would you rather write? Prior art in other languages is the proof this makes sense.

Let’s say this RFC passes and is released in the next PHP version. If I don’t use static analysers today and a library I depend on introduces native generics, nothing changes for me. Same way I violated @template PHPDocs before, now I’m violating native generics instead.

If someone uses @template PHPDocs today in their own code, you can assume they almost certainly also runs a static analyser. No one is forcing anyone to adopt all language features. Same way the match expression isn’t for everyone, bound-erased generic types don’t have to be for everyone.

But there are many PHP developers who would welcome this and have been beating the drum for many years. The PHP documentation about this feature could mention the compromises needed to deliver this anticipated feature. And if someone misuses that (introduces generics in their code without checking with static analyser), well, that’s a bug they’ve introduced — the same class of bug already possible today with @template.

Ondřej Mirtes

Hi!

I choose that word carefully: it is not type erasure per se that I am against, but the ability to write code which *looks like* it enforces particular signatures, but actually does not.

If one of the concerns is that `Collection<int>` looks like something
that will be checked at runtime, but actually will not be, would it
make sense to introduce erased generics together with syntax that
explicitly marks the type declaration as erased/unchecked?

For example:

function foo(erased Collection<int> $c): void {}

If the keyword is considered too verbose, a symbolic marker could also
be considered:

function foo(@Collection<int> $c): void {}

or:

function foo(-Collection<int> $c): void {}

The exact spelling is obviously bikeshedding. My point is more about
where the marker should be placed semantically: perhaps the marker
should be on the erased/unchecked form, rather than on a future
reified form.

In the current discussion, one possible answer to future reified
generics is that they could be introduced as an opt-in feature later.
However, if the disagreement is about the expectation that PHP type
declarations are enforced at runtime by default, then it seems more
consistent to mark the erased form explicitly.

That would give users three distinct forms:

Collection
// runtime class check only
// no claim about type arguments; raw/unparameterized/unknown

@Collection<User>
// erased generic annotation
// at runtime, only Collection is checked
// User is preserved for static analysis and Reflection
// the marker could be a keyword such as `erased`, or some symbol;
// the exact spelling is not the important part

Collection<User>
// left available for a future reified / runtime-checked meaning

This would avoid making the most natural spelling, `Collection<User>`,
mean “looks checked, but is actually erased”. Users who primarily want
static analysis support could still opt into the erased form
explicitly, while the unmarked syntax would remain available for a
future runtime-checked model.

Thanks!

--
Shinji Igarashi

2026年5月11日(月) 21:12 Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk>:

On 10 May 2026 20:02:32 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:
>Hello Internals,
>
>I'd like to start the discussion on a new RFC adding bound-erased
>generics types to PHP.

Hi Seifeddine,

Thanks for putting together this proposal.

However, I am generally against any *unenforced* implementation of generics. I choose that word carefully: it is not type erasure per se that I am against, but the ability to write code which *looks like* it enforces particular signatures, but actually does not.

There is lots of background in the RFC, but one relevant comparison not referenced is Python, where all type hints are unenforced. Static analysis has to be run manually, so code can be written, and even published as a library, with completely incorrect type information. Users of that code then make reasonable assumptions based on the published types, and get unexpected run-time behaviour.

Note that this is fundamentally different from the experience of languages like Java or TypeScript, where type information may be erased, but is still *enforced*, because static analysis is a compulsory part of the compilation process.

Having most types enforced is obviously better than none, but as far as I can see the same risk exists - someone can write code that claims to use generics, and publish it to Packagist without actually checking it; or write supposed invariants which only hold if the user also analyses their own code. This is true today, of course, but the claims are much weaker, sitting in docblocks not native syntax.

Once we go down this route, it will be very hard to change our minds, because enforcing types in code which previously ignored them would be a breaking change.

I think the only options I would personally support are:

a) run-time enforced generics in limited positions (as in the blog post you link to from Gina and Larry)

b) generics which are erased at compile-time *by a tool that also enforces them* (as in Java, TypeScript, etc)

Regards,

Rowan Tommins
[IMSoP]

On 5/12/26 14:41, Rowan Tommins [IMSoP] wrote:

On 12 May 2026 13:01:44 BST, Seifeddine Gmati<azjezz@carthage.software> wrote:

The audience for native generics is the same audience that already
uses docblock generics, which is the same audience that already runs
SA.

This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption.

As soon as PHP ships native syntax for generics, an entirely new set of users will hear about it and try to use it. Their experience is going to be shaped by what *PHP itself* does with that new syntax.

I would also argue that the flipside applies: if the audience for the feature really is existing users of third-party SA tools, then it shouldn't be shipped as part of php-src. The language should only provide the building blocks for those tools to work with - and attributes seem perfectly suited here.

It's not the job of this list to create standards for what attributes third-party tools support.

Rowan Tommins
[IMSoP]

What is true for I guess every PHP developer is that they are using a production and development environment, with a different config in each environment. Now I am not particularly fond of adding more configuration settings, but maybe in this case a /generics_mode = erased | bound-erased | reified/ could help to ship generics now.

As I understand the discussion on generics, the problem with reified generics, besides the fact there is no implementation at the moment, would be performance. My solution to this would be to */teach/*, from the start, not to use reified generics in production. Set the default of the setting to erased or bound-erased and suggest people to enable reified in development only. This is where good documentation and community effort would be vital to teach this entirely new set of users how to deal with this language feature. You would then develop in a more strict environment than in production. It is not that this new to developers. Developers have been transpiling their strict development typescript and ES6 code to loose/erased JS for at least a decade now. They have able to teach that, why would PHP not be able to teach their decisions?

Anyway, until we have reified generics, performant or not, behind a setting or not, I think that by teaching this new set of users what bound-erased means and does, why it is there, by explaining which direction the language is going, I would very much welcome bound-erased generics. But I believe there would also have to be some kind of consensus on the direction after bound-erased generics. That is I guess the hardest part of this RFC. I wish Seifeddine good luck in going forward.

Regards,

Frederik Bosch
Maintainer of MoneyPHP

Am 10.05.2026, 21:02:32 schrieb Seifeddine Gmati azjezz@carthage.software:

Hello Internals,

I’d like to start the discussion on a new RFC adding bound-erased
generics types to PHP.

Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.

Thanks,
Seifeddine.

Hi Seifeddine,

I like this proposal very much, I personally don’t think its a problem that some types are erased and some are not.

However, I do understand the arguments of others. As a compromise, we could introduce a declare:

declare(erased_types=generic_only);

If this is not specified, then using generic syntax will throw an exception.

And it could give the option in the future to add two modes:

declare(erased_types=all);
declare(erased_types=none);

I’d prefer to land with a version without declare’s, but in this instance I fear we need a temporary solution to get this off the ground until all the pieces are in place.

greetings
Benjamin

This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption.

The PHP 7.0 type-declaration rollout is the closest empirical test.
Native scalar types shipped to a community that had been using PHPDoc
@param and @return annotations for years. They didn't suddenly create
a new population of developers who type their code, some popular
projects argued the new types were useless and refused to adopt them.
The audience that gained native syntax was the audience that had
already been typing their code. People who didn't see value in types
before PHP 7.0 mostly didn't see value after, either.

The same dynamic will apply here. People who don't care about generic
type information today won't suddenly care because PHP grew the
syntax. The audience that uses @template in docblocks is the audience
that will use native generics.

if the audience for the feature really is existing users of third-party SA tools, then it shouldn't be shipped as part of php-src. The language should only provide the building blocks for those tools to work with - and attributes seem perfectly suited here.

Attributes themselves are a counter-example. They shipped in PHP 8.0
specifically to formalize what frameworks had been doing in docblocks.
Their audience at launch was framework users, exactly the kind of
"existing third-party-tool users" your argument says shouldn't justify
language-level support. PHP shipped them anyway, resulting in benefits
for everyone: first-class syntax, parser validation, Reflection
access, IDE support, cleaner code. The same applies to generics: the
ecosystem has been doing this work in docblocks for a decade;
formalizing it in the language is the consistent next step.

Beyond that, expressing generics through attributes specifically
doesn't work. Attribute syntax is in the form of name-call-arguments
(`#[Template("T", bound: "object")]`); it can't express type parameter
references, variance markers, bound-on-bound, or substitution into
method signatures. You'd end up with worse ergonomics than docblocks
for the same information, and the parser still wouldn't understand the
type relationships. The static analysis ecosystem explored and
rejected the "attributes are enough" path as too limited.

Hi Carlos,

Thanks for the feedback.

- It would be great to have some mention of the performance cost of this feature, both for code which does not use generics and for code that does use them

The design goal is zero runtime cost for code that doesn't use
generics. Code paths that don't go through turbofish should compile to
byte-identical bytecode as without the RFC. The verify opcode is only
emitted at turbofish sites, so non-generic code never executes it. The
current implementation shows around 0.1% benchmark drift in either
direction, which is at the noise floor and within the margin you'd
expect from any unrelated engine change. I haven't focused on
performance optimization yet, the goal at this stage is correctness,
not micro-tuning. However, the final number should show zero impact
for non-generic code. Code that does use turbofish pays the cost of
the arity-and-bound check at the call site, which is dominated by the
type comparison itself (the same type comparison the engine does for
any typed parameter), not by dispatch overhead.

- It would be great to be able to see how the userland samples from Laravel, Doctrine, etc... would look when using this new syntax, maybe you could add a new section with this

Agreed. I'll add a parallel section showing what each example looks
like in the proposed native syntax, so readers can see the migration
directly.

Cheers,
Seifeddine.

Hi Benjamin,

Thanks for thinking through this.

I'd push back on the declare approach.

I'd prefer to land with a version without declare's, but in this instance I fear we need a temporary solution to get this off the ground until all the pieces are in place.

The "temporary until all the pieces are in place" framing doesn't
quite hold. There's no committed path or timeline for reified
generics, no implementation work currently in flight, and no clear
answer to whether the runtime cost of reification can be brought to
acceptable levels. A declare added on the assumption that it
transitions to something else later would, realistically, remain in
PHP for the indefinite future. PHP doesn't remove declare directives;
`declare(ticks)` remains in the language decades after anyone uses it
(at least as far as I'm aware).

declare(erased_types=generic_only);

If this is not specified, then using generic syntax will throw an exception.

Requiring `declare(erased_types=...)` to use the syntax raises the
adoption barrier rather than lowering it. The point of native generics
over docblocks is that the syntax should be easier and more
discoverable, not require ceremony at the top of every file. A library
exposing generic APIs would need the `declare` in every file.
Consumers would copy-paste it as boilerplate, and new users would hit
"why isn't my generic syntax working" errors and have to learn about a
`declare` they didn't need to know about.

`declare(strict_types=1)` has already shown how "opt-in via declare"
plays out in practice. It shipped as a per-file choice but the
ecosystem turned it into mandatory boilerplate. Linter rules, style
guides, and codebase conventions all push toward "every file gets the
declare." I have a linter rule that adds it to every PHP file in my
projects, including config files that just `return [...];`, because
remembering when it does and doesn't matter is mental overhead I'd
rather not pay. Most serious PHP teams I know operate the same way.
Adding `declare(erased_types=...)` would create the same dynamic
within months, except now codebases have two declares to maintain
consistency on, and any file using generic syntax errors without it.
You'd ship a feature that's nominally opt-in but ecosystem-mandatory,
which is the worst of both worlds.

And it could give the option in the future to add two modes:

declare(erased_types=all);
declare(erased_types=none);

The granularity is wrong for the future direction you're imagining. If
reified generics ever ship, the right opt-in is per-class or
per-function, not per-file. The strictness-vs-cost tradeoff is a
code-level decision; a single class wanting reified semantics
shouldn't require everything else in the file to share that choice.
Hack made this exact decision: their `reify` keyword applies
per-type-parameter (`function foo<reify T>(T $x)`), not per-file. The
granularity matches the design space.

`strict_types` is the PHP precedent worth comparing to, but in a
different way than your proposal uses it. It's per-file because scalar
coercion is a clear binary with a sensible default. Generics doesn't
have a clear binary; it has a spectrum (fully erased -> bound erased
-> reified -> fully dependent?), and the design space for "what gets
enforced at runtime" is still being explored. Locking in a syntactic
mechanism now for a feature whose semantics aren't settled would
constrain future RFCs unnecessarily.

The RFC as written doesn't preclude any of the directions you're
imagining. If reified generics ever ships, it'll ship with an opt-in
mechanism that fits the granularity of the actual feature, debated and
decided at the time it's proposed. That's the right place to make that
decision, not pre-loaded into this RFC.

Cheers,
Seifeddine.

Hi Seifeddine,

Thank you so much for making and pushing for this RFC.

As the current maintainer of Psalm, I fully support the overall design of the RFC, and I especially support the choice of erased generics, considering the current state of the PHP ecosystem and of php-src itself.

Choosing erased generics has two major benefits:

  • Acknowledging and building upon the currently existing rich ecosystem of static analysers, without significantly changing/breaking existing generic semantics already used by the ecosystem and by the community.
  • Not locking down semantics for PHP itself by letting future RFCs and implementations do the (very) heavy lifting of runtime validation, using for example monomorphized generics, or i.e. with a static analyzer API, to plug existing static analyzers directly into PHP itself (one of the more promising approaches in my opinion, creating a true TypePHP without rewriting yet another static analyser from scratch, re-using existing, tested and working tools).

I will implement support for this RFC in Psalm immediately after the RFC is approved (fingers crossed).

I have just a few minor notes after briefly skimming the RFC and the discussion (absolute non-blockers, just a bit of bikeshedding :slight_smile:

  1. Turbofish at callsite: this was already brought up before, while I understand the precedence issues that led to this choice, having written some rust myself, I still don’t like the syntax, and would very much prefer normal diamond syntax at callsite, like for declarations.

Without diving too deeply into the parser, it seems to me that enabling plain diamond syntax should be easy-ish at least with class instantiation using new, even though some of the worst offenders when it comes to readability aren’t class instantiations but rather static calls:

[self::foo::<Bar>(1), self::foo::<Bar>(2)] // :(
  1. -/+ for variant bounds: also brought up before, while it can be somewhat mnemonic (- consumes for input params, + produces for output params), in/out would indeed be much more descriptive IMO (or at the very least, both options could be provided at the same time).

  2. More of a wording issue, the RFC describes the new reflection API as the primary way to consume generics, but the main consumers (static analysers) will consume them using nikic/php-parser (as usual, unless another RFC is made to bring proper AST-based parsing infrastructure into PHP itself :), with the new reflection generic API being used only for native/extension generic APIs.

Kind regards,
Daniil Gentili

Daniil Gentili - Senior software artisan

Portfolio: https://daniil.it
Telegram: https://t.me/danogentili

On 13 May 2026 17:19:21 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:

This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption.

The PHP 7.0 type-declaration rollout is the closest empirical test.
Native scalar types shipped to a community that had been using PHPDoc
@param and @return annotations for years. They didn't suddenly create
a new population of developers who type their code, some popular
projects argued the new types were useless and refused to adopt them.
The audience that gained native syntax was the audience that had
already been typing their code. People who didn't see value in types
before PHP 7.0 mostly didn't see value after, either.

I don't think this is true at all. Users were writing "array", and class/interface types, in their code for many years before PHP 7.0, *and having them natively enforced*. Most of those users had never heard of static analysers, but as soon as static types became available, using them was entirely natural.

If you survey current code bases, I bet you a drink of your choice that you will find code bases with some use of scalar types outnumbering code bases which have been tested with a Static Analyser by an order of magnitude.

The same dynamic will apply here. People who don't care about generic
type information today won't suddenly care because PHP grew the
syntax. The audience that uses @template in docblocks is the audience
that will use native generics.

Of course they will, because it will suddenly be much more visible. Every "what's new in PHP" blog post will describe the new syntax, and people will start playing with it. People who see docblocks as "just documentation" will see frameworks and libraries putting it in "actual types" and copy it into their own code.

And this is a good thing! We *want* new language features to be interesting to more people. But we also want them to be *useful* to those people, not documented in the manual as "advanced users only; if you're the target audience for this, you probably don't need this page".

Attributes themselves are a counter-example. They shipped in PHP 8.0
specifically to formalize what frameworks had been doing in docblocks.

Attributes are very explicitly an *abstract extension point* for tooling to do what it wants with. PHP does not attempt to standardise their use; it doesn't even validate that attribute names correspond to valid classes unless you ask it to. PHP provides some attributes out of the box, but only when it also includes some *behaviour* for those attributes.

In the same way, PHP provides the ability to define interfaces. It also provides interfaces to interact with included features, like SessionHandlerInterface. But it leaves it up to the community to agree interfaces for things that are not included with the language, like LoggerInterface or CacheInterface.

That's not the same as what you're proposing.

Beyond that, expressing generics through attributes specifically
doesn't work.

Fair enough. My point stands though: the language should provide abstract extension points, or working implementations, not empty syntax.

There's also the elephant in the room that the proposal doesn't remove the need for standardising a docblock or attribute approach anyway, because it is inevitably going to miss things the SA tools support: class-string<T>, array<int,Foo>, iterable<Bar>, non-empty-string, ...

That again follows from it not being an abstract extension point like docblocks and attributes.

Advanced users, who you say are the target audience, will still have to work with both syntaxes; and will still find differences between tools which aren't covered by the subset of validation that PHP has taken ownership of.

Rowan Tommins
[IMSoP]

- RFC: PHP: rfc:bound_erased_generic_types

A few updates were made to the RFC today:

- Migration diff: full patch showing PSL's Graph package moving from
`@template` to native syntax.

- Secondary vote on variance markers: `+T`/`-T` vs `in T`/`out T`.
- Performance section:

- Enforcement framing is promoted; turbofish optionality now leads its section.
- `ReflectionGenericVariance` is now a unit enum.

Thanks to everyone who has reviewed.

Cheers,
Seifeddine.