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

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.

- RFC: PHP: rfc:bound_erased_generic_types
- Implementation: PHP RFC: Bound-Erased Generic Types by azjezz · Pull Request #21969 · php/php-src · GitHub

Thanks,
Seifeddine.

Hey Seifeddine,

···

On 10.5.2026 21:02:32, Seifeddine Gmati 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.

- RFC: [https://wiki.php.net/rfc/bound_erased_generic_types](https://wiki.php.net/rfc/bound_erased_generic_types)
- Implementation: [https://github.com/php/php-src/pull/21969](https://github.com/php/php-src/pull/21969)

Thanks,
Seifeddine.

I have a bunch of questions and feedback:

The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box, U: Box>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.

Your tests also show restrictions around intersection types, e.g. “Type parameter T with bound mixed cannot be part of an intersection type” for ‘class Foo {} function x(): T & Foo {}’. What’s the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.

I would like to plead to skip the arity validation, except for “more parameters than allowed”:

  • This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
  • It would also make addition of generics to Iterator classes etc. completely uncontroversial.
  • This would be more in line with PHP’s general “no type is effectively the highest possible bound” approach. I.e. “class A extends Box” and “class A extends Box” would be equivalent.
  • This would also allow for future incremental runtime generics: you’d start with and as you call stuff with values, the type becomes broader.

This is the one thing which makes the whole RFC a non-starter for me if required:

Typing is optional in PHP!

Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?

class Container {}
function f(Container $x): Container { return $x; }

Diamond checks:

Are these necessarily problematic? if you inherit Box and Box, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it’ll evaluate to never.

If you disagree (that’s possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box, Box. At least at a glance I don’t find such a test - if it already works, nice, then please just add the test!

Is class ABox implements Box allowed, or do we need to write implements Box?

I’m also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the array syntax, but honestly, I’d rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e. [A<B, B>(C)] would continue carrying the meaning it has today, and we’d require writing [(A<B, B>(C))] for that case.

I’m not quite sure if + and - are the proper choices. I’m more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed “+” to be covariant - the sum of stuff accepted, and “-” contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.

Otherwise, it’s a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)

Thanks,
Bob

Hi Bob,

Hey Seifeddine,

On 10.5.2026 21:02:32, Seifeddine Gmati 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.

- RFC: PHP: rfc:bound_erased_generic_types
- Implementation: PHP RFC: Bound-Erased Generic Types by azjezz · Pull Request #21969 · php/php-src · GitHub

Thanks,
Seifeddine.

I have a bunch of questions and feedback:

The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.

Your tests also show restrictions around intersection types, e.g. "Type parameter T with bound mixed cannot be part of an intersection type" for 'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.

I would like to plead to skip the arity validation, except for "more parameters than allowed":
- This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
  - It would also make addition of generics to Iterator classes etc. completely uncontroversial.
- This would be more in line with PHP's general "no type is effectively the highest possible bound" approach. I.e. "class A extends Box" and "class A extends Box<mixed>" would be equivalent.
- This would also allow for future incremental runtime generics: you'd start with <never> and as you call stuff with values, the type becomes broader.

This is the one thing which makes the whole RFC a non-starter for me if required:

Typing is optional in PHP!

Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?

class Container {}
function f(Container<int> $x): Container<string> { return $x; }

Diamond checks:

Are these necessarily problematic? if you inherit Box<int> and Box<string>, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it'll evaluate to never.

If you disagree (that's possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box<string>, Box<int>. At least at a glance I don't find such a test - if it already works, nice, then please just add the test!

Is class ABox implements Box<self> allowed, or do we need to write implements Box<ABox>?

I'm also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the array syntax, but honestly, I'd rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e. `[A<B, B>(C)]` would continue carrying the meaning it has today, and we'd require writing `[(A<B, B>(C))]` for that case.

I'm not quite sure if + and - are the proper choices. I'm more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed "+" to be covariant - the sum of stuff accepted, and "-" contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.

Otherwise, it's a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)

Thanks,
Bob

Thanks for the careful read. Going point by point.

1. Ordering of type parameter declarations

The restriction is implementation-level, not fundamental. We register
parameter names before we compile bounds, so allowing <T: Box<U>, U:
Box<T>> is a "small" change. I left it out for the initial cut because
I didn't want to bake mutually-recursive bounds into the spec without
seeing whether anyone actually wants them in practice. If others agree
this is worth having, I'm happy to drop the restriction before vote.

2. Type parameters in intersection types

The check rejects an intersection where one side is a type parameter
whose bound is `mixed`, because the erased form can be anything,
including a scalar. Scalars don't intersect with anything, today. (
ref Online PHP editor | output for mdvFA )

The error message in the test you saw is precisely about the unbounded
case. If `T` is bound to an object-shaped type (`T: object`, `T:
SomeInterface`, `T: SomeClass`, ...), then `T & Foo` is allowed. the
erased form is guaranteed to be a legal intersection operand. So this
is the same rule PHP already enforces today, just applied through the
erased form.

3. Arity validation at consumer call sites

I think this one is a misunderstanding. Arity validation only fires
when the caller writes turbofish. Without turbofish, nothing changes
at the call site:

function id<T>(T $v): T { return $v; }

id($x);                 // no validation, no behavior change
id::<int>($x);       // arity + bound checked

So a library can add generic parameters to its public surface and
every existing caller (none of which uses turbofish, because turbofish
doesn't exist today) keeps working unchanged. The validation is opt-in
at the use site. Same for `new` and method calls.

This is exactly the graceful-addition story you're asking for. The
existing tests demonstrate it.

4. Generic args on a non-generic class in a signature

class Container {}
function f(Container<int> $x): Container<string> { return $x; }

This is accepted, and on purpose. PHP doesn't load classes from
signatures, they load on use: Online PHP editor | output for DnIKQ

To validate arity at compile time, we'd have to load `Container`,
which is a behavioral and performance regression. The cost of being
strict here is much higher than the cost of being permissive. The same
logic that already lets you reference an unloaded class in a signature
lets you reference an unloaded class with type arguments in a
signature. Validation happens once the class actually gets resolved at
a use site (new, turbofish call, etc.).

5. Diamond inheritance

The diamond check is necessary because methods get substituted with
the type arguments at link time. Consider:

interface Box<T> { public function set(T $v): void; }

class C implements Box<int>, Box<string> {}

After substitution, C must implement both `set(int): void` and
`set(string): void`. PHP has no way to represent two methods with the
same name and different signatures ( i.e overloading ), one of them
has to win, and either choice silently breaks one of the parent
contracts. Same problem in contravariant position. The check rejects
this at link time rather than letting it produce a class that violates
its own interface.

For purely covariant slots you have a point, `get(): int` and `get():
string` could in principle be reconciled to `get(): int|string` (an
LUB). The current implementation rejects all diamonds uniformly to
keep linking deterministic and to avoid synthesizing union types
during inheritance. Relaxing it for the covariant case is a reasonable
follow-up, not something I want to bake in before vote.

6. `class ABox implements Box<self>`

It is allowed and works as you'd expect. `self` resolves to the
implementing class.

interface Box<+T> { public function get(): T; }

class ABox implements Box<self> {
    public function get(): self { return $this; }
}

var_dump((new ABox)->get() instanceof ABox); // true

7. Turbofish

We have to disagree here. Turbofish:
  - has zero parser conflict with comparison operators in expression position
  - is uniform across `new`, function calls, method calls, FCCs, attributes
  - requires no context-sensitive disambiguation rule

The alternative adds a rule a developer has to learn and apply at
exactly the worst places (inside attributes, array expressions,
ternaries). I'd rather pay the `::` tax than introduce a
context-sensitive parser rule that bites people inside attributes
specifically. Rust's choice was a forced one because of `<>` overload,
and it's the right one for PHP too for the same reason.

8. + / - markers

Picked because they don't require any new reserved words. `in`/`out`
reads well but I'm not comfortable burning two keywords for a feature
where two pieces of punctuation already do the job.

On the "+ = sum of accepted" intuition: the convention here is the
standard one from variance literature. `+` marks positions where the
type can be widened (covariant, e.g., returns), `-` marks positions
where it can be narrowed (contravariant, e.g., parameters). It also
matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
already maps to.

9. Runtime generics

Agreed entirely. The design is bound-erased *for now*. Nothing in it
precludes a follow-up RFC adding reified generics at the inheritance
level. That slice is the most useful and the cleanest to bolt on, the
engine's type-parameter representation is already structured to
support it.

Cheers,
Seifeddine.

Thanks for the quick reply!

Let me respond inline to avoid backtracking too much.

interface A { public function set(int $v): void; } interface B { public function set(string $v): void; } class C implements A, B { public function set(int|string $v): void {} }

···

On 11.5.2026 01:05:25, Seifeddine Gmati wrote:

I have a bunch of questions and feedback:

The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.

Your tests also show restrictions around intersection types, e.g. "Type parameter T with bound mixed cannot be part of an intersection type" for 'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.

I would like to plead to skip the arity validation, except for "more parameters than allowed":
- This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
  - It would also make addition of generics to Iterator classes etc. completely uncontroversial.
- This would be more in line with PHP's general "no type is effectively the highest possible bound" approach. I.e. "class A extends Box" and "class A extends Box<mixed>" would be equivalent.
- This would also allow for future incremental runtime generics: you'd start with <never> and as you call stuff with values, the type becomes broader.

This is the one thing which makes the whole RFC a non-starter for me if required:

Typing is optional in PHP!

Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?

class Container {}
function f(Container<int> $x): Container<string> { return $x; }

Diamond checks:

Are these necessarily problematic? if you inherit Box<int> and Box<string>, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it'll evaluate to never.

If you disagree (that's possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box<string>, Box<int>. At least at a glance I don't find such a test - if it already works, nice, then please just add the test!

Is class ABox implements Box<self> allowed, or do we need to write implements Box<ABox>?

I'm also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the array syntax, but honestly, I'd rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e. `[A<B, B>(C)]` would continue carrying the meaning it has today, and we'd require writing `[(A<B, B>(C))]` for that case.

I'm not quite sure if + and - are the proper choices. I'm more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed "+" to be covariant - the sum of stuff accepted, and "-" contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.

Otherwise, it's a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)

Thanks,
Bob

Thanks for the careful read. Going point by point.

1. Ordering of type parameter declarations

The restriction is implementation-level, not fundamental. We register
parameter names before we compile bounds, so allowing <T: Box<U>, U:
Box<T>> is a "small" change. I left it out for the initial cut because
I didn't want to bake mutually-recursive bounds into the spec without
seeing whether anyone actually wants them in practice. If others agree
this is worth having, I'm happy to drop the restriction before vote.

Yes, that’s the impression I had - an arbitrary restriction to make it a bit simpler at compile time.

I’d suggest just dropping it, why have it, actually? It should be a relatively easy change. I don’t see any concrete advantage of this, apart from the minor simplification this restriction would have in compiler.

2. Type parameters in intersection types

The check rejects an intersection where one side is a type parameter
whose bound is `mixed`, because the erased form can be anything,
including a scalar. Scalars don't intersect with anything, today. (
ref [https://3v4l.org/mdvFA#v](https://3v4l.org/mdvFA#v) )

The error message in the test you saw is precisely about the unbounded
case. If `T` is bound to an object-shaped type (`T: object`, `T:
SomeInterface`, `T: SomeClass`, ...), then `T & Foo` is allowed. the
erased form is guaranteed to be a legal intersection operand. So this
is the same rule PHP already enforces today, just applied through the
erased form.

Ah, I see, it needs a T: object. (or named class). It’s not quite obvious from the error message, so I’d suggest adding a suggestion for “at least T: object or a stronger bound” then.

That makes some sense. The question would be if never types should be possible to reached, but this I’ve basically asked already when asking about diamond checks.

3. Arity validation at consumer call sites

I think this one is a misunderstanding. Arity validation only fires
when the caller writes turbofish. Without turbofish, nothing changes
at the call site:

```
function id<T>(T $v): T { return $v; }

id($x);                 // no validation, no behavior change
id::<int>($x);       // arity + bound checked
```

So a library can add generic parameters to its public surface and
every existing caller (none of which uses turbofish, because turbofish
doesn't exist today) keeps working unchanged. The validation is opt-in
at the use site. Same for `new` and method calls.

This is exactly the graceful-addition story you're asking for. The
existing tests demonstrate it.

This is not quite obvious from the RFC. I’d recommend adding a subsection to “What is enforced where” detailing that these are not checked: I thought “turbofish arity” would apply to everywhere, not just explicitly where the ::<> syntax is actually used.

Are they also not checked for inheritance? Or just for caller sites?

Sorry for missing it in tests, you have a LOT of tests!

4. Generic args on a non-generic class in a signature

```
class Container {}
function f(Container<int> $x): Container<string> { return $x; }
```

This is accepted, and on purpose. PHP doesn't load classes from
signatures, they load on use: [https://3v4l.org/DnIKQ#v](https://3v4l.org/DnIKQ#v)

To validate arity at compile time, we'd have to load `Container`,
which is a behavioral and performance regression. The cost of being
strict here is much higher than the cost of being permissive. The same
logic that already lets you reference an unloaded class in a signature
lets you reference an unloaded class with type arguments in a
signature. Validation happens once the class actually gets resolved at
a use site (new, turbofish call, etc.).

I’m actually suggesting validation at runtime here, i.e. once the class type check passes, to check whether the arity is matching for the class of the argument.

I’m certainly not asking for compile time checks here. But leaving this unchecked sort-of makes it the odd-one out here.

5. Diamond inheritance

The diamond check is necessary because methods get substituted with
the type arguments at link time. Consider:

```
interface Box<T> { public function set(T $v): void; }

class C implements Box<int>, Box<string> {}
```

After substitution, C must implement both `set(int): void` and
`set(string): void`. PHP has no way to represent two methods with the
same name and different signatures ( i.e overloading ), one of them
has to win, and either choice silently breaks one of the parent
contracts. Same problem in contravariant position. The check rejects
this at link time rather than letting it produce a class that violates
its own interface.

For purely covariant slots you have a point, `get(): int` and `get():
string` could in principle be reconciled to `get(): int|string` (an
LUB). The current implementation rejects all diamonds uniformly to
keep linking deterministic and to avoid synthesizing union types
during inheritance. Relaxing it for the covariant case is a reasonable
follow-up, not something I want to bake in before vote.

You got it the wrong way round, the union needs to be allowed on the parameters, not the return type.

set(string): void and set(int): void can be merged into set(string|int): void.

I’d also like to mention here that:

is perfectly valid today. Not allowing this for the contravariant case would make it inconsistent with what’s currently supported in PHP.

This needs no overloading at all.

6. `class ABox implements Box<self>`

It is allowed and works as you'd expect. `self` resolves to the
implementing class.

```
interface Box<+T> { public function get(): T; }

class ABox implements Box<self> {
    public function get(): self { return $this; }
}

var_dump((new ABox)->get() instanceof ABox); // true
```

Nice!

7. Turbofish

We have to disagree here. Turbofish:
  - has zero parser conflict with comparison operators in expression position
  - is uniform across `new`, function calls, method calls, FCCs, attributes
  - requires no context-sensitive disambiguation rule

The alternative adds a rule a developer has to learn and apply at
exactly the worst places (inside attributes, array expressions,
ternaries). I'd rather pay the `::` tax than introduce a
context-sensitive parser rule that bites people inside attributes
specifically. Rust's choice was a forced one because of `<>` overload,
and it's the right one for PHP too for the same reason.

Alright, let’s disagree here.

8. + / - markers

Picked because they don't require any new reserved words. `in`/`out`
reads well but I'm not comfortable burning two keywords for a feature
where two pieces of punctuation already do the job.

On the "+ = sum of accepted" intuition: the convention here is the
standard one from variance literature. `+` marks positions where the
type can be widened (covariant, e.g., returns), `-` marks positions
where it can be narrowed (contravariant, e.g., parameters). It also
matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
already maps to.

I understand, I’ve never used any of those languages for more than targeted edits, so I didn’t know.

I guess it’s fine to not diverge here.

By the way, you don’t necessarily need a new keyword, in fact you could just allow two consecutive T_STRING at that position and emit a parser error when the first one is neither of “in” or “out”.

Thanks,
Bob

Hi Bob,

Thanks for the quick reply!

Let me respond inline to avoid backtracking too much.

On 11.5.2026 01:05:25, Seifeddine Gmati wrote:

I have a bunch of questions and feedback:

The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.

Your tests also show restrictions around intersection types, e.g. "Type parameter T with bound mixed cannot be part of an intersection type" for 'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.

I would like to plead to skip the arity validation, except for "more parameters than allowed":
- This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
  - It would also make addition of generics to Iterator classes etc. completely uncontroversial.
- This would be more in line with PHP's general "no type is effectively the highest possible bound" approach. I.e. "class A extends Box" and "class A extends Box<mixed>" would be equivalent.
- This would also allow for future incremental runtime generics: you'd start with <never> and as you call stuff with values, the type becomes broader.

This is the one thing which makes the whole RFC a non-starter for me if required:

Typing is optional in PHP!

Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?

class Container {}
function f(Container<int> $x): Container<string> { return $x; }

Diamond checks:

Are these necessarily problematic? if you inherit Box<int> and Box<string>, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it'll evaluate to never.

If you disagree (that's possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box<string>, Box<int>. At least at a glance I don't find such a test - if it already works, nice, then please just add the test!

Is class ABox implements Box<self> allowed, or do we need to write implements Box<ABox>?

I'm also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the array syntax, but honestly, I'd rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e. `[A<B, B>(C)]` would continue carrying the meaning it has today, and we'd require writing `[(A<B, B>(C))]` for that case.

I'm not quite sure if + and - are the proper choices. I'm more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed "+" to be covariant - the sum of stuff accepted, and "-" contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.

Otherwise, it's a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)

Thanks,
Bob

Thanks for the careful read. Going point by point.

1. Ordering of type parameter declarations

The restriction is implementation-level, not fundamental. We register
parameter names before we compile bounds, so allowing <T: Box<U>, U:
Box<T>> is a "small" change. I left it out for the initial cut because
I didn't want to bake mutually-recursive bounds into the spec without
seeing whether anyone actually wants them in practice. If others agree
this is worth having, I'm happy to drop the restriction before vote.

Yes, that's the impression I had - an arbitrary restriction to make it a bit simpler at compile time.

I'd suggest just dropping it, why have it, actually? It should be a relatively easy change. I don't see any concrete advantage of this, apart from the minor simplification this restriction would have in compiler.

2. Type parameters in intersection types

The check rejects an intersection where one side is a type parameter
whose bound is `mixed`, because the erased form can be anything,
including a scalar. Scalars don't intersect with anything, today. (
ref Online PHP editor | output for mdvFA )

The error message in the test you saw is precisely about the unbounded
case. If `T` is bound to an object-shaped type (`T: object`, `T:
SomeInterface`, `T: SomeClass`, ...), then `T & Foo` is allowed. the
erased form is guaranteed to be a legal intersection operand. So this
is the same rule PHP already enforces today, just applied through the
erased form.

Ah, I see, it needs a T: object. (or named class). It's not quite obvious from the error message, so I'd suggest adding a suggestion for "at least T: object or a stronger bound" then.

That makes some sense. The question would be if never types should be possible to reached, but this I've basically asked already when asking about diamond checks.

3. Arity validation at consumer call sites

I think this one is a misunderstanding. Arity validation only fires
when the caller writes turbofish. Without turbofish, nothing changes
at the call site:

function id<T>(T $v): T { return $v; }

id($x);                 // no validation, no behavior change
id::<int>($x);       // arity + bound checked

So a library can add generic parameters to its public surface and
every existing caller (none of which uses turbofish, because turbofish
doesn't exist today) keeps working unchanged. The validation is opt-in
at the use site. Same for `new` and method calls.

This is exactly the graceful-addition story you're asking for. The
existing tests demonstrate it.

This is not quite obvious from the RFC. I'd recommend adding a subsection to "What is enforced where" detailing that these are *not* checked: I thought "turbofish arity" would apply to everywhere, not just explicitly where the ::<> syntax is actually used.

Are they also not checked for inheritance? Or just for caller sites?

Sorry for missing it in tests, you have a LOT of tests!

4. Generic args on a non-generic class in a signature

class Container {}
function f(Container<int> $x): Container<string> { return $x; }

This is accepted, and on purpose. PHP doesn't load classes from
signatures, they load on use: Online PHP editor | output for DnIKQ

To validate arity at compile time, we'd have to load `Container`,
which is a behavioral and performance regression. The cost of being
strict here is much higher than the cost of being permissive. The same
logic that already lets you reference an unloaded class in a signature
lets you reference an unloaded class with type arguments in a
signature. Validation happens once the class actually gets resolved at
a use site (new, turbofish call, etc.).

I'm actually suggesting validation at runtime here, i.e. once the class type check passes, to check whether the arity is matching for the class of the argument.

I'm certainly not asking for compile time checks here. But leaving this unchecked sort-of makes it the odd-one out here.

5. Diamond inheritance

The diamond check is necessary because methods get substituted with
the type arguments at link time. Consider:

interface Box<T> { public function set(T $v): void; }

class C implements Box<int>, Box<string> {}

After substitution, C must implement both `set(int): void` and
`set(string): void`. PHP has no way to represent two methods with the
same name and different signatures ( i.e overloading ), one of them
has to win, and either choice silently breaks one of the parent
contracts. Same problem in contravariant position. The check rejects
this at link time rather than letting it produce a class that violates
its own interface.

For purely covariant slots you have a point, `get(): int` and `get():
string` could in principle be reconciled to `get(): int|string` (an
LUB). The current implementation rejects all diamonds uniformly to
keep linking deterministic and to avoid synthesizing union types
during inheritance. Relaxing it for the covariant case is a reasonable
follow-up, not something I want to bake in before vote.

You got it the wrong way round, the union needs to be allowed on the parameters, not the return type.

set(string): void and set(int): void can be merged into set(string|int): void.

I'd also like to mention here that:

interface A { public function set(int $v): void; }
interface B { public function set(string $v): void; }
class C implements A, B { public function set(int|string $v): void {} }

is perfectly valid today. Not allowing this for the contravariant case would make it inconsistent with what's currently supported in PHP.

This needs no overloading at all.

6. `class ABox implements Box<self>`

It is allowed and works as you'd expect. `self` resolves to the
implementing class.

interface Box<+T> { public function get(): T; }

class ABox implements Box<self> {
    public function get(): self { return $this; }
}

var_dump((new ABox)->get() instanceof ABox); // true

Nice!

7. Turbofish

We have to disagree here. Turbofish:
  - has zero parser conflict with comparison operators in expression position
  - is uniform across `new`, function calls, method calls, FCCs, attributes
  - requires no context-sensitive disambiguation rule

The alternative adds a rule a developer has to learn and apply at
exactly the worst places (inside attributes, array expressions,
ternaries). I'd rather pay the `::` tax than introduce a
context-sensitive parser rule that bites people inside attributes
specifically. Rust's choice was a forced one because of `<>` overload,
and it's the right one for PHP too for the same reason.

Alright, let's disagree here.

8. + / - markers

Picked because they don't require any new reserved words. `in`/`out`
reads well but I'm not comfortable burning two keywords for a feature
where two pieces of punctuation already do the job.

On the "+ = sum of accepted" intuition: the convention here is the
standard one from variance literature. `+` marks positions where the
type can be widened (covariant, e.g., returns), `-` marks positions
where it can be narrowed (contravariant, e.g., parameters). It also
matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
already maps to.

I understand, I've never used any of those languages for more than targeted edits, so I didn't know.

I guess it's fine to not diverge here.

By the way, you don't necessarily need a new keyword, in fact you could just allow two consecutive T_STRING at that position and emit a parser error when the first one is neither of "in" or "out".

Thanks,
Bob

Thanks for the reply!

1. Ordering of type parameter declarations

Agreed, dropped. Forward references and mutually recursive bounds
within a single parameter list are now allowed:

function f<U : T, T>(U $x): T { /* ... */ }            // forward
class Pair<T : Box<U>, U : Box<T>> {}                  // mutual

Defaults still require backward-only references, meaning omitted
arguments resolve in one pass at instantiation. Direct self-reference
at the head of a bound (`<T : T>`) is still rejected; the indirect
form (`<T : Box<T>>`) is still allowed.

Diff: Comparing 9ebcf28cef5563a63fe0bcc2fd5ec45211fa1f15..f8ced4dacacda2038118bcc889f4905a92cf05de · php/php-src · GitHub

2. Intersection error message

Improved the diagnostic to point directly at the fix instead of just
stating the rule. The message now reads:

Type parameter T with bound mixed cannot be part of an intersection
type; use an object-shaped bound (e.g. T: object)

Diff:
Thanks for the reply!

1. Ordering of type parameter declarations

Agreed, dropped. Forward references and mutually recursive bounds
within a single parameter list are now allowed:

function f<U : T, T>(U $x): T { /* ... */ }            // forward
class Pair<T : Box<U>, U : Box<T>> {}                  // mutual

Defaults still require backward-only references, meaning omitted
arguments resolve in one pass at instantiation. Direct self-reference
at the head of a bound (`<T : T>`) is still rejected; the indirect
form (`<T : Box<T>>`) is still allowed.

Diff: Comparing 9ebcf28cef5563a63fe0bcc2fd5ec45211fa1f15..f8ced4dacacda2038118bcc889f4905a92cf05de · php/php-src · GitHub

2. Intersection error message

Improved the diagnostic to point directly at the fix instead of just
stating the rule. The message now reads:

Type parameter T with bound mixed cannot be part of an intersection
type; use an object-shaped bound (e.g. T: object)

Diff: Comparing f8ced4dacacda2038118bcc889f4905a92cf05de..6b5588d8e927d60e4b4509658af62601dfa0802a · php/php-src · GitHub

3. Arity validation at consumer call sites

You're right that the RFC didn't say this clearly. Added a "What is
not checked" subsection under "What is enforced where" that lists the
exact sites where the engine intentionally omits arity or bounds
validation. The whole point of opt-in is the graceful-addition story
you're after; the RFC now spells that out.

4. Runtime arity check at call boundaries

In principle, sure. Once the runtime confirms the value matches the
class, we can also validate the signature's type arguments against the
class's actual declared arity and bounds. So in:

class C {}
function foo(C<int, string> $x): void {}

foo(new C());

we'd error because `C` has no generic parameters but the signature
supplied two type arguments.

The catch is that this isn't only about parameters. It applies to
every place the engine resolves a class-typed type expression at
runtime.

So a bit more complicated, not a small change. I want to spend more
time on it before committing to text: what exactly gets validated,
where the result gets cached so we aren't paying for it on every typed
call, how it interacts with the substitution chain at link time, and
what the hot-path cost actually is on a profiled workload. I'd like to
see what others on the list think too, since the call is a trade-off
between strictness and performance and people will weigh those
differently.

If after that the answer is "yes, fold it in", I'll fold it in. But I
don't want to promise it inside this RFC until I've done the
investigation.

5. Diamond inheritance - I had the direction wrong

Yea, sorry. Contravariant (parameter) positions are the ones that
merge cleanly into a union, not return positions. Your example is
right.

For the generic case, the contravariant side is the easy one:

interface Box<-T> { public function set(T $v): void; }

class C implements Box<int>, Box<string> { /* set(int|string) */ }

The implementer's substituted prototype is the union of the two
contravariant slots.

The covariant side is more nuanced. `get(): int` and `get(): string`
merged would have to return both ( i.e. `int & string` ), and PHP
rejects intersections involving scalars (because impossible!). So a
covariant diamond with scalar bindings is unrepresentable. It only
becomes representable when the type parameter is bounded by an
object-shaped type, in which case the implementer's return type
collapses cleanly to an intersection:

interface Box<+T : object> { public function get(): T; }
interface A {}
interface B {}

class C implements Box<A>, Box<B> {
    public function get(): A&B { /* ... */ }
}

Here `A & B` is a valid PHP intersection, so the merge is sound.

I'll look into this. I think we can fit it into this RFC, but I want
to investigate the implementation first. I keep the RFC and the
implementation in sync and don't want to commit to text that isn't
backed by working code yet.

Thanks,
Seifeddine.

3. Arity validation at consumer call sites

You're right that the RFC didn't say this clearly. Added a "What is
not checked" subsection under "What is enforced where" that lists the
exact sites where the engine intentionally omits arity or bounds
validation. The whole point of opt-in is the graceful-addition story
you're after; the RFC now spells that out.

4. Runtime arity check at call boundaries

In principle, sure. Once the runtime confirms the value matches the
class, we can also validate the signature's type arguments against the
class's actual declared arity and bounds. So in:

class C {}
function foo(C<int, string> $x): void {}

foo(new C());

we'd error because `C` has no generic parameters but the signature
supplied two type arguments.

The catch is that this isn't only about parameters. It applies to
every place the engine resolves a class-typed type expression at
runtime.

So a bit more complicated, not a small change. I want to spend more
time on it before committing to text: what exactly gets validated,
where the result gets cached so we aren't paying for it on every typed
call, how it interacts with the substitution chain at link time, and
what the hot-path cost actually is on a profiled workload. I'd like to
see what others on the list think too, since the call is a trade-off
between strictness and performance and people will weigh those
differently.

If the answer after that is "yes, add it,", I'll fold it in. But I
don't want to promise it inside this RFC until I've done the
investigation.

5. Diamond inheritance

Yea, sorry. Contravariant (parameter) positions are the ones that
merge cleanly into a union, not return positions. Your example is
right.

For the generic case, the contravariant side is the easy one:

interface Box<-T> { public function set(T $v): void; }

class C implements Box<int>, Box<string> { /* set(int|string) */ }

The implementer's substituted prototype is the union of the two
contravariant slots. No new type-system rules are needed.

The covariant side is more nuanced. `get(): int` and `get(): string`
merged would have to return both ( i.e. `int & string` ), and PHP
rejects intersections involving scalars (because impossible!). So a
covariant diamond with scalar bindings is unrepresentable. It only
becomes representable when the type parameter is bounded by an
object-shaped type, in which case the implementer's return type
collapses cleanly to an intersection:

interface Box<+T : object> { public function get(): T; }
interface A {}
interface B {}

class C implements Box<A>, Box<B> {
    public function get(): A&B { /* ... */ }
}

Here `A & B` is a valid PHP intersection, so the merge is sound.

I'll look into this. I think we can fit it into this RFC, but I want
to investigate the implementation first. I keep the RFC and the
implementation in sync and don't want to commit to text that isn't
backed by working code yet.

Thanks,
Seifeddine.

On Sun, May 10, 2026 at 1:05 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.

- RFC: PHP: rfc:bound_erased_generic_types
- Implementation: PHP RFC: Bound-Erased Generic Types by azjezz · Pull Request #21969 · php/php-src · GitHub

Thanks,
Seifeddine.

Seifeddine,

This is a very interesting RFC, thank you!

I have a technical request: can we lower the limit from 255 to a 7-bit
max? I've done tons of optimization work in the last 8-10 years of my
life, and having a spare bit on things for the future has often been
rewarded. And for me, I can't imagine a use-case for having 128-255
type arguments in practice. Do you have any evidence that a 7-bit
maximum would be insufficient for real-world code?

Thanks, Levi Morrison

On Sun, May 10, 2026 at 1:05 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.
>
> - RFC: PHP: rfc:bound_erased_generic_types
> - Implementation: PHP RFC: Bound-Erased Generic Types by azjezz · Pull Request #21969 · php/php-src · GitHub
>
> Thanks,
> Seifeddine.

Seifeddine,

This is a very interesting RFC, thank you!

I have a technical request: can we lower the limit from 255 to a 7-bit
max? I've done tons of optimization work in the last 8-10 years of my
life, and having a spare bit on things for the future has often been
rewarded. And for me, I can't imagine a use-case for having 128-255
type arguments in practice. Do you have any evidence that a 7-bit
maximum would be insufficient for real-world code?

Thanks, Levi Morrison

Hi Levi,

Good catch, lowered. The cap is now 127 (u7), with the high bit
reserved for future per-site metadata. RFC updated:

Honestly, there is no real-world evidence either way, no PHP codebase
approaches either 127 or 255 type parameters, so this is purely about
reserving headroom.

Thanks,
Seifeddine.

Hi Bob,

>
> Thanks for the quick reply!
>
> Let me respond inline to avoid backtracking too much.
>
> On 11.5.2026 01:05:25, Seifeddine Gmati wrote:
>
> I have a bunch of questions and feedback:
>
> The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.
>
> Your tests also show restrictions around intersection types, e.g. "Type parameter T with bound mixed cannot be part of an intersection type" for 'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.
>
> I would like to plead to skip the arity validation, except for "more parameters than allowed":
> - This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
> - It would also make addition of generics to Iterator classes etc. completely uncontroversial.
> - This would be more in line with PHP's general "no type is effectively the highest possible bound" approach. I.e. "class A extends Box" and "class A extends Box<mixed>" would be equivalent.
> - This would also allow for future incremental runtime generics: you'd start with <never> and as you call stuff with values, the type becomes broader.
>
>
> This is the one thing which makes the whole RFC a non-starter for me if required:
>
> Typing is optional in PHP!
>
>
> Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?
>
> class Container {}
> function f(Container<int> $x): Container<string> { return $x; }
>
>
> Diamond checks:
>
> Are these necessarily problematic? if you inherit Box<int> and Box<string>, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it'll evaluate to never.
>
> If you disagree (that's possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box<string>, Box<int>. At least at a glance I don't find such a test - if it already works, nice, then please just add the test!
>
>
> Is class ABox implements Box<self> allowed, or do we need to write implements Box<ABox>?
>
>
> I'm also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
> I understand that there are language limitations, in particular with the array syntax, but honestly, I'd rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e. `[A<B, B>(C)]` would continue carrying the meaning it has today, and we'd require writing `[(A<B, B>(C))]` for that case.
>
>
> I'm not quite sure if + and - are the proper choices. I'm more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed "+" to be covariant - the sum of stuff accepted, and "-" contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.
>
>
> Otherwise, it's a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)
>
>
> Thanks,
> Bob
>
> Thanks for the careful read. Going point by point.
>
> 1. Ordering of type parameter declarations
>
> The restriction is implementation-level, not fundamental. We register
> parameter names before we compile bounds, so allowing <T: Box<U>, U:
> Box<T>> is a "small" change. I left it out for the initial cut because
> I didn't want to bake mutually-recursive bounds into the spec without
> seeing whether anyone actually wants them in practice. If others agree
> this is worth having, I'm happy to drop the restriction before vote.
>
> Yes, that's the impression I had - an arbitrary restriction to make it a bit simpler at compile time.
>
> I'd suggest just dropping it, why have it, actually? It should be a relatively easy change. I don't see any concrete advantage of this, apart from the minor simplification this restriction would have in compiler.
>
> 2. Type parameters in intersection types
>
> The check rejects an intersection where one side is a type parameter
> whose bound is `mixed`, because the erased form can be anything,
> including a scalar. Scalars don't intersect with anything, today. (
> ref Online PHP editor | output for mdvFA )
>
> The error message in the test you saw is precisely about the unbounded
> case. If `T` is bound to an object-shaped type (`T: object`, `T:
> SomeInterface`, `T: SomeClass`, ...), then `T & Foo` is allowed. the
> erased form is guaranteed to be a legal intersection operand. So this
> is the same rule PHP already enforces today, just applied through the
> erased form.
>
> Ah, I see, it needs a T: object. (or named class). It's not quite obvious from the error message, so I'd suggest adding a suggestion for "at least T: object or a stronger bound" then.
>
> That makes some sense. The question would be if never types should be possible to reached, but this I've basically asked already when asking about diamond checks.
>
> 3. Arity validation at consumer call sites
>
> I think this one is a misunderstanding. Arity validation only fires
> when the caller writes turbofish. Without turbofish, nothing changes
> at the call site:
>
> ```
> function id<T>(T $v): T { return $v; }
>
> id($x); // no validation, no behavior change
> id::<int>($x); // arity + bound checked
> ```
>
> So a library can add generic parameters to its public surface and
> every existing caller (none of which uses turbofish, because turbofish
> doesn't exist today) keeps working unchanged. The validation is opt-in
> at the use site. Same for `new` and method calls.
>
> This is exactly the graceful-addition story you're asking for. The
> existing tests demonstrate it.
>
> This is not quite obvious from the RFC. I'd recommend adding a subsection to "What is enforced where" detailing that these are *not* checked: I thought "turbofish arity" would apply to everywhere, not just explicitly where the ::<> syntax is actually used.
>
> Are they also not checked for inheritance? Or just for caller sites?
>
> Sorry for missing it in tests, you have a LOT of tests!
>
> 4. Generic args on a non-generic class in a signature
>
> ```
> class Container {}
> function f(Container<int> $x): Container<string> { return $x; }
> ```
>
> This is accepted, and on purpose. PHP doesn't load classes from
> signatures, they load on use: Online PHP editor | output for DnIKQ
>
> To validate arity at compile time, we'd have to load `Container`,
> which is a behavioral and performance regression. The cost of being
> strict here is much higher than the cost of being permissive. The same
> logic that already lets you reference an unloaded class in a signature
> lets you reference an unloaded class with type arguments in a
> signature. Validation happens once the class actually gets resolved at
> a use site (new, turbofish call, etc.).
>
> I'm actually suggesting validation at runtime here, i.e. once the class type check passes, to check whether the arity is matching for the class of the argument.
>
> I'm certainly not asking for compile time checks here. But leaving this unchecked sort-of makes it the odd-one out here.
>
> 5. Diamond inheritance
>
> The diamond check is necessary because methods get substituted with
> the type arguments at link time. Consider:
>
> ```
> interface Box<T> { public function set(T $v): void; }
>
> class C implements Box<int>, Box<string> {}
> ```
>
> After substitution, C must implement both `set(int): void` and
> `set(string): void`. PHP has no way to represent two methods with the
> same name and different signatures ( i.e overloading ), one of them
> has to win, and either choice silently breaks one of the parent
> contracts. Same problem in contravariant position. The check rejects
> this at link time rather than letting it produce a class that violates
> its own interface.
>
> For purely covariant slots you have a point, `get(): int` and `get():
> string` could in principle be reconciled to `get(): int|string` (an
> LUB). The current implementation rejects all diamonds uniformly to
> keep linking deterministic and to avoid synthesizing union types
> during inheritance. Relaxing it for the covariant case is a reasonable
> follow-up, not something I want to bake in before vote.
>
> You got it the wrong way round, the union needs to be allowed on the parameters, not the return type.
>
> set(string): void and set(int): void can be merged into set(string|int): void.
>
> I'd also like to mention here that:
>
> interface A { public function set(int $v): void; }
> interface B { public function set(string $v): void; }
> class C implements A, B { public function set(int|string $v): void {} }
>
> is perfectly valid today. Not allowing this for the contravariant case would make it inconsistent with what's currently supported in PHP.
>
> This needs no overloading at all.
>
> 6. `class ABox implements Box<self>`
>
> It is allowed and works as you'd expect. `self` resolves to the
> implementing class.
>
> ```
> interface Box<+T> { public function get(): T; }
>
> class ABox implements Box<self> {
> public function get(): self { return $this; }
> }
>
> var_dump((new ABox)->get() instanceof ABox); // true
> ```
>
> Nice!
>
>
> 7. Turbofish
>
> We have to disagree here. Turbofish:
> - has zero parser conflict with comparison operators in expression position
> - is uniform across `new`, function calls, method calls, FCCs, attributes
> - requires no context-sensitive disambiguation rule
>
> The alternative adds a rule a developer has to learn and apply at
> exactly the worst places (inside attributes, array expressions,
> ternaries). I'd rather pay the `::` tax than introduce a
> context-sensitive parser rule that bites people inside attributes
> specifically. Rust's choice was a forced one because of `<>` overload,
> and it's the right one for PHP too for the same reason.
>
> Alright, let's disagree here.
>
>
> 8. + / - markers
>
> Picked because they don't require any new reserved words. `in`/`out`
> reads well but I'm not comfortable burning two keywords for a feature
> where two pieces of punctuation already do the job.
>
> On the "+ = sum of accepted" intuition: the convention here is the
> standard one from variance literature. `+` marks positions where the
> type can be widened (covariant, e.g., returns), `-` marks positions
> where it can be narrowed (contravariant, e.g., parameters). It also
> matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
> already maps to.
>
> I understand, I've never used any of those languages for more than targeted edits, so I didn't know.
>
> I guess it's fine to not diverge here.
>
> By the way, you don't necessarily need a new keyword, in fact you could just allow two consecutive T_STRING at that position and emit a parser error when the first one is neither of "in" or "out".
>
>
> Thanks,
> Bob

Thanks for the reply!

1. Ordering of type parameter declarations

Agreed, dropped. Forward references and mutually recursive bounds
within a single parameter list are now allowed:

function f<U : T, T>(U $x): T { /* ... */ }            // forward
class Pair<T : Box<U>, U : Box<T>> {}                  // mutual

Defaults still require backward-only references, meaning omitted
arguments resolve in one pass at instantiation. Direct self-reference
at the head of a bound (`<T : T>`) is still rejected; the indirect
form (`<T : Box<T>>`) is still allowed.

Diff: Comparing 9ebcf28cef5563a63fe0bcc2fd5ec45211fa1f15..f8ced4dacacda2038118bcc889f4905a92cf05de · php/php-src · GitHub

2. Intersection error message

Improved the diagnostic to point directly at the fix instead of just
stating the rule. The message now reads:

Type parameter T with bound mixed cannot be part of an intersection
type; use an object-shaped bound (e.g. T: object)

Diff:
Thanks for the reply!

1. Ordering of type parameter declarations

Agreed, dropped. Forward references and mutually recursive bounds
within a single parameter list are now allowed:

function f<U : T, T>(U $x): T { /* ... */ }            // forward
class Pair<T : Box<U>, U : Box<T>> {}                  // mutual

Defaults still require backward-only references, meaning omitted
arguments resolve in one pass at instantiation. Direct self-reference
at the head of a bound (`<T : T>`) is still rejected; the indirect
form (`<T : Box<T>>`) is still allowed.

Diff: Comparing 9ebcf28cef5563a63fe0bcc2fd5ec45211fa1f15..f8ced4dacacda2038118bcc889f4905a92cf05de · php/php-src · GitHub

2. Intersection error message

Improved the diagnostic to point directly at the fix instead of just
stating the rule. The message now reads:

Type parameter T with bound mixed cannot be part of an intersection
type; use an object-shaped bound (e.g. T: object)

Diff: Comparing f8ced4dacacda2038118bcc889f4905a92cf05de..6b5588d8e927d60e4b4509658af62601dfa0802a · php/php-src · GitHub

3. Arity validation at consumer call sites

You're right that the RFC didn't say this clearly. Added a "What is
not checked" subsection under "What is enforced where" that lists the
exact sites where the engine intentionally omits arity or bounds
validation. The whole point of opt-in is the graceful-addition story
you're after; the RFC now spells that out.

4. Runtime arity check at call boundaries

In principle, sure. Once the runtime confirms the value matches the
class, we can also validate the signature's type arguments against the
class's actual declared arity and bounds. So in:

class C {}
function foo(C<int, string> $x): void {}

foo(new C());

we'd error because `C` has no generic parameters but the signature
supplied two type arguments.

The catch is that this isn't only about parameters. It applies to
every place the engine resolves a class-typed type expression at
runtime.

So a bit more complicated, not a small change. I want to spend more
time on it before committing to text: what exactly gets validated,
where the result gets cached so we aren't paying for it on every typed
call, how it interacts with the substitution chain at link time, and
what the hot-path cost actually is on a profiled workload. I'd like to
see what others on the list think too, since the call is a trade-off
between strictness and performance and people will weigh those
differently.

If after that the answer is "yes, fold it in", I'll fold it in. But I
don't want to promise it inside this RFC until I've done the
investigation.

5. Diamond inheritance - I had the direction wrong

Yea, sorry. Contravariant (parameter) positions are the ones that
merge cleanly into a union, not return positions. Your example is
right.

For the generic case, the contravariant side is the easy one:

interface Box<-T> { public function set(T $v): void; }

class C implements Box<int>, Box<string> { /* set(int|string) */ }

The implementer's substituted prototype is the union of the two
contravariant slots.

The covariant side is more nuanced. `get(): int` and `get(): string`
merged would have to return both ( i.e. `int & string` ), and PHP
rejects intersections involving scalars (because impossible!). So a
covariant diamond with scalar bindings is unrepresentable. It only
becomes representable when the type parameter is bounded by an
object-shaped type, in which case the implementer's return type
collapses cleanly to an intersection:

interface Box<+T : object> { public function get(): T; }
interface A {}
interface B {}

class C implements Box<A>, Box<B> {
    public function get(): A&B { /* ... */ }
}

Here `A & B` is a valid PHP intersection, so the merge is sound.

I'll look into this. I think we can fit it into this RFC, but I want
to investigate the implementation first. I keep the RFC and the
implementation in sync and don't want to commit to text that isn't
backed by working code yet.

Thanks,
Seifeddine.

3. Arity validation at consumer call sites

You're right that the RFC didn't say this clearly. Added a "What is
not checked" subsection under "What is enforced where" that lists the
exact sites where the engine intentionally omits arity or bounds
validation. The whole point of opt-in is the graceful-addition story
you're after; the RFC now spells that out.

4. Runtime arity check at call boundaries

In principle, sure. Once the runtime confirms the value matches the
class, we can also validate the signature's type arguments against the
class's actual declared arity and bounds. So in:

class C {}
function foo(C<int, string> $x): void {}

foo(new C());

we'd error because `C` has no generic parameters but the signature
supplied two type arguments.

The catch is that this isn't only about parameters. It applies to
every place the engine resolves a class-typed type expression at
runtime.

So a bit more complicated, not a small change. I want to spend more
time on it before committing to text: what exactly gets validated,
where the result gets cached so we aren't paying for it on every typed
call, how it interacts with the substitution chain at link time, and
what the hot-path cost actually is on a profiled workload. I'd like to
see what others on the list think too, since the call is a trade-off
between strictness and performance and people will weigh those
differently.

If the answer after that is "yes, add it,", I'll fold it in. But I
don't want to promise it inside this RFC until I've done the
investigation.

5. Diamond inheritance

Yea, sorry. Contravariant (parameter) positions are the ones that
merge cleanly into a union, not return positions. Your example is
right.

For the generic case, the contravariant side is the easy one:

interface Box<-T> { public function set(T $v): void; }

class C implements Box<int>, Box<string> { /* set(int|string) */ }

The implementer's substituted prototype is the union of the two
contravariant slots. No new type-system rules are needed.

The covariant side is more nuanced. `get(): int` and `get(): string`
merged would have to return both ( i.e. `int & string` ), and PHP
rejects intersections involving scalars (because impossible!). So a
covariant diamond with scalar bindings is unrepresentable. It only
becomes representable when the type parameter is bounded by an
object-shaped type, in which case the implementer's return type
collapses cleanly to an intersection:

interface Box<+T : object> { public function get(): T; }
interface A {}
interface B {}

class C implements Box<A>, Box<B> {
    public function get(): A&B { /* ... */ }
}

Here `A & B` is a valid PHP intersection, so the merge is sound.

I'll look into this. I think we can fit it into this RFC, but I want
to investigate the implementation first. I keep the RFC and the
implementation in sync and don't want to commit to text that isn't
backed by working code yet.

Thanks,
Seifeddine.

Hi Bob,

Pulled an all-nighter on #5 because the more I thought about it the
more it seemed like the right thing to do, and it turned out to
compose really cleanly with the existing parametric LSP machinery.
Both directions are now in:

Contravariant diamond -> union merge
Covariant diamond on object-bounded T -> intersection merge

The RFC has a new "Diamond inheritance" subsection covering both:

Genuinely happy with how this turned out. Example from the RFC:

interface Renderable {}
interface Cacheable {}

class Article implements Renderable, Cacheable {}

interface Pipeline<T : object> {
    public function process(T $value): T;
}

interface RenderingPipeline extends Pipeline<Renderable> {}
interface CachingPipeline extends Pipeline<Cacheable> {}

class ArticlePipeline implements
    RenderingPipeline,
    CachingPipeline,
    Pipeline<Renderable & Cacheable>
{
    public function process(Renderable | Cacheable $value): Renderable
& Cacheable {
        if ($value instanceof Renderable && $value instanceof Cacheable) {
            return $value;
        }
        return new Article();
    }
}

The synthesized prototype across the three views is
`process(Renderable | Cacheable): Renderable & Cacheable`. The merge
is computed at link time against PHP's existing union and intersection
operators, so no new type-system rules are needed.

Thanks for pushing on this :smiley:

Cheers,
Seifeddine.

Thank you, Seifeddine, for all this work and effort; it was a very clear and even pleasant read. To internals: I’d like to highlight a couple of additional reasons why I think you should vote yes on this final RFC.

  1. Although runtime-ignored generics are a paradigm shift compared to PHP’s current type system, it’s important to note that the target audience for this feature is already very familiar with this workflow. The value of generics (and by extension, the whole type system) for them comes from static type analysis, not the runtime type checker. Why do we need dedicated syntax then, and not stick with docblocks? Because of the reasons outlined in the RFC: a consistent spec and consistent syntax.
  2. Regarding the consistent specification, you may know I work at PhpStorm, so I’d like to highlight how much of a pain the current situation is for us (and also other static analysis vendors). There is no consistency in the details. We have a huge backlog of issues regarding generic type checking that are unsolvable without a proper and consistent spec. PHPStan does X, Psalm does Y, Mago does Z; and most developers expect PhpStorm to support everything. Furthermore, performance is much more a concern for us, as we run our type checker in real time. We would love to improve our generic type support, but a proper spec is required to ensure consistency and a clear path forward. Full disclosure, we tried bringing all static analysis vendors together five or six years ago to create this consistent spec ourselves. These efforts failed, and the only viable option we see is if the spec came from internals.
  3. Regarding adding new syntax that doesn’t really do anything, there is precedent with attributes. Similar to generics, annotations were a docblock-only feature that got dedicated syntax, without any runtime effect besides reflection. That’s exactly the mindset this RFC embraces, and attributes were very well received by the PHP community. I understand some people may fear runtime-ignored generics causing confusion, but I don’t think this will be a problem, given that the target audience is: one, used to this workflow already; and, two, attributes already introduced this concept of “syntax that has no runtime effect”.

I hope that, along with all the arguments Seifeddine made in the RFC, you seriously consider voting yes on the final RFC, even if you yourself aren’t the target audience for this feature. The vast majority of people who would benefit from generics are the people already using static analysis. Speaking with them for years both online and offline, I know most are on board with this approach. (I say “most of them”, but truthfully, everyone I spoke with over the years is on board. I just avoid saying “all” because I’m sure someone somewhere disagrees).

Have a good day!

Brent

On Sun, May 10, 2026 at 9:04 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.

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]

Hi Rowan,

I think the core premise is empirically testable, and the test has
already run: PHP has had generics in docblocks for a decade, used by
every major framework. If "someone publishes a library with broken
type information and downstream users get surprised by runtime
behavior" were a real failure mode, we would see it today, as Laravel,
Doctrine, Symfony, PSL, and PHPUnit all run on `@template`-annotated
code and are consumed by millions of downstream applications. The
failure mode you describe is the one that would occur most under the
current system, yet it doesn't.

The reason it doesn't is that the people who care about generic type
information are the same people who run static analysis. The
intersection of "uses generics" and "doesn't run a static analyzer" is
approximately empty in practice. Native syntax doesn't change that. It
just gives current users a better way to express what they mean.

Regarding the second point, the proposed implementation is enforced
more at compile time than docblocks are. The engine validates:

- Generic parameter declarations: arity cap, bound conformance,
default-vs-bound, no top-level self-reference, no shadowing.
- Variance soundness: Covariant parameters cannot appear in input
positions, contravariant parameters cannot appear in output positions,
at the declaration site.
- Inheritance: arity at extends/implements/use, bound conformance with
bound-on-bound for forwarded parameters, diamond detection, parametric
LSP into properties/hooks/trait methods/inherited methods.
- Turbofish at runtime: arity and bounds at every call site that
supplies type arguments.

Docblocks validate none of this; the engine accepts a docblock that
says anything. Native syntax is a stricter contract than what the
ecosystem has today.

Finally, on the "broken code published to Packagist" concern: someone
can already publish:

/** Map the given array using the provided callable. */
function map(array $array, callable $fn): array {
    return [];
}

The signature claims to take an array and a callable, returns an
array. The body returns an empty array regardless. Today, this passes
every PHP type check, parses without error, runs in production, and is
technically conformant. Nobody is calling for PHP to ship a [precious
type system](https://www.youtube.com/watch?v=Ay-gnCqDw9o) with support
for [lifetimes](Explicit annotation - Rust By Example),
and [const generic
parameters](Generic parameters - The Rust Reference)
to prevent it (e.g. `function map<'a, I, O, const SIZE: int>(vec<&'a
I, SIZE> & ![] $array, fn(&'a I): &'a O $fn): vec<&'a O, SIZE> &
![]`). The same logic applies to generics: the contract a user writes
is the contract a user is responsible for honoring, and PHP's role is
to provide tools for expressing and checking that contract, not to
make every conceivable mistake impossible.

The options you listed both close off paths the PHP ecosystem has
already shown it doesn't want.

Cheers,
Seifeddine.

On Mon, May 11, 2026 at 1:13 PM Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk> wrote:

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.

I’m of course not a voter but I’d like to just expand on some possible concerns around the particular implementation that’s being proposed; if it was just purely static generics in first party syntax, I think in some ways that would be better than what’s actually being proposed. Instead the RFC, at least as I’m understanding it, seems to land in a potentially awkward middle ground between fully erased generics and runtime enforcement, where generics become semantically significant in inheritance and reflection but not for runtime type identity.

One thing that stands out to me is that this introduces a jarring asymmetry with how types work in PHP in any other respect. The case today is that where there’s type information in code, the engine is checking and enforcing it (subject to some well known caveats around coercion, but still, there is behaviour there). This breaks that model and I think it’s something people who will be voting ought to consider.

If this RFC passes as-is, we’ll be in a place where all the things it introduces are sensitive to backwards compatibility indefinitely going forward. So it rules out any kind of reification in future, no generic-aware instanceof checks, no opcache optimizations, etc. And I know some people will say we will never have those things anyway and that this is better than no generics at all, and I can see the case for that, I’m just pointing out this isn’t making a decision about a feature, it’s making a fundamental, long-term decision about language architecture. The proposed form is introducing all the design complexity of generics for developers, but really only for the gain that existing static analysis tools already provide, while the primary runtime/pre-runtime and optimization benefits of generics in any other language are still missing. And I’m not convinced by pointing to Hack’s reify keyword, because it’s a different case. Hack had a comparatively very small and centralised ecosystem versus PHP, the reach of erased generics in PHP once it sets in, in terms of design decisions, framework adoption, etc will be so large that no future “opt in” retrofitting of anything else will be plausible in the same way.

It may be that all these things are okay from a cost-benefit perspective. I’m not saying I’m against it here, just that everyone owes it to themselves and every user to make sure this is the right decision, just because of the magnitude and impact this RFC carries.

I’m not personally competent to give any meaningful feedback on the actual C implementation, but I can see most of it came from a single, enormous commit which will undoubtedly make meaningful review difficult. I hope someone who is both more C proficient and familiar with the engine’s internals is up to the task.

On 5/11/26 03:38, Brent Roose wrote:

Thank you, Seifeddine, for all this work and effort; it was a very clear
and even pleasant read. To internals: I'd like to highlight a couple of
additional reasons why I think you should vote yes on this final RFC.

1. Although runtime-ignored generics are a paradigm shift compared to PHP's
current type system, it's important to note that the target audience for
this feature is already very familiar with this workflow. The value of
generics (and by extension, the whole type system) for them comes from
static type analysis, not the runtime type checker. Why do we need
dedicated syntax then, and not stick with docblocks? Because of the reasons
outlined in the RFC: a consistent spec and consistent syntax.

Many of those on this list who contribute to these discussions are also part of the target audience. Are you trying to make the point that people on internals are not the target audience?

2. Regarding the consistent specification, you may know I work at PhpStorm,
so I'd like to highlight how much of a pain the current situation is for us
(and also other static analysis vendors). There is no consistency in the
details. We have a huge backlog of issues regarding generic type checking
that are unsolvable without a proper and consistent spec. PHPStan does X,
Psalm does Y, Mago does Z; and most developers expect PhpStorm to support
everything. Furthermore, performance is much more a concern for us, as we
run our type checker in real time. We would love to improve our generic
type support, but a proper spec is required to ensure consistency and a
clear path forward. Full disclosure, we tried bringing all static analysis
vendors together five or six years ago to create this consistent spec
ourselves. These efforts failed, and the only viable option we see is if
the spec came from internals.

Can you share some of the reasons these efforts failed? If each of these static analysis tools experience problems with conflicting behavior and support, then it seems to me that it behooves all of them to work together to develop a consistent spec. Would PHPStan, Psalm, Mago, etc. be interested in throwing their support behind this RFC (or one like it)?

3. Regarding adding new syntax that doesn't really do anything, there is
precedent with attributes. Similar to generics, annotations were a
docblock-only feature that got dedicated syntax, without any runtime effect
besides reflection. That's exactly the mindset this RFC embraces, and
attributes were very well received by the PHP community. I understand some
people may fear runtime-ignored generics causing confusion, but I don't
think this will be a problem, given that the target audience is: one, used
to this workflow already; and, two, attributes already introduced this
concept of "syntax that has no runtime effect".

There are a handful of predefined attributes that do have runtime (or maybe compile-time?) effects:

- AllowDynamicProperties - allows a class to have dynamic properties
- Deprecated - causes an E_USER_DEPRECATED error to be emitted
- NoDiscard - emits warning if return value not used
- Override - fatal error if parent doesn't define method
- ReturnTypeWillChange - silences a deprecation notice
- SensitiveParameter - redacts values from stack traces

I hope that, along with all the arguments Seifeddine made in the RFC, you
seriously consider voting yes on the final RFC, even if you yourself aren't
the target audience for this feature. The vast majority of people who would
benefit from generics are the people already using static analysis.
Speaking with them for years both online and offline, I know most are on
board with this approach. (I say "most of them", but truthfully, everyone I
spoke with over the years is on board. I just avoid saying "all" because
I'm sure someone somewhere disagrees).

Have you spoken to folks on this list? I'm part of the target audience, and I've personally been vocal against any proposal for erased types both here and on social media. The one proposal I've been somewhat supportive of is the compile-time generics that Gina and Larry worked on last year[1]. I'm not sure what state it's currently in.

Cheers,
Ben

[1]: Compile time generics: yay or nay? — The PHP Foundation — Supporting, Advancing, and Developing the PHP Language

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.

Hi Seifeddine,

Many thanks for such a detailed and interesting proposal. There is one thing that I have not seen specifically addressed in the RFC which is performance.

What is the performance cost of this implementation for:
a) code that does not use Generics
b) code that uses Generics

If you could provide some data about this I think it would be a great addition to the RFC

Cheers

Carlos

On 5/11/26 13:03, Ben Ramsey wrote:

On 5/11/26 03:38, Brent Roose wrote:

2. Regarding the consistent specification, you may know I work at PhpStorm,
so I'd like to highlight how much of a pain the current situation is for us
(and also other static analysis vendors). There is no consistency in the
details. We have a huge backlog of issues regarding generic type checking
that are unsolvable without a proper and consistent spec. PHPStan does X,
Psalm does Y, Mago does Z; and most developers expect PhpStorm to support
everything. Furthermore, performance is much more a concern for us, as we
run our type checker in real time. We would love to improve our generic
type support, but a proper spec is required to ensure consistency and a
clear path forward. Full disclosure, we tried bringing all static analysis
vendors together five or six years ago to create this consistent spec
ourselves. These efforts failed, and the only viable option we see is if
the spec came from internals.

Can you share some of the reasons these efforts failed? If each of these static analysis tools experience problems with conflicting behavior and support, then it seems to me that it behooves all of them to work together to develop a consistent spec. Would PHPStan, Psalm, Mago, etc. be interested in throwing their support behind this RFC (or one like it)?

Apologies to Seifeddine. I forgot they maintain Mago. :slight_smile:

But my question still stands for PHPStan, Psalm, and others.

Cheers,
Ben

On 11/05/2026 17:25, Seifeddine Gmati wrote:

I think the core premise is empirically testable, and the test has
already run: PHP has had generics in docblocks for a decade, used by
every major framework. If "someone publishes a library with broken
type information and downstream users get surprised by runtime
behavior" were a real failure mode, we would see it today, as Laravel,
Doctrine, Symfony, PSL, and PHPUnit all run on `@template`-annotated
code and are consumed by millions of downstream applications. The
failure mode you describe is the one that would occur most under the
current system, yet it doesn't.

How do you know? There could be packages on Packagist right now, where the "@template" annotations are completely wrong. The only people who would notice would be those who use that library AND subject it to static analysis; and half of them would probably be too lazy to raise a bug report or PR, and just mark it ignored locally.

The reason it doesn't is that the people who care about generic type
information are the same people who run static analysis. The
intersection of "uses generics" and "doesn't run a static analyzer" is
approximately empty in practice. Native syntax doesn't change that. It
just gives current users a better way to express what they mean.

I think this reasoning is fundamentally flawed. Right now, generic annotations *only exist* as part of those static analysis tools; so of course, the people using the annotations are the people using the tools. What you're proposing is to add syntax to the language itself, which will be a big headline feature of a new version of PHP, and documented in the manual on php.net. That is 100% guaranteed to change the audience for the feature.

- Generic parameter declarations: arity cap, bound conformance,
default-vs-bound, no top-level self-reference, no shadowing.
- Variance soundness: Covariant parameters cannot appear in input
positions, contravariant parameters cannot appear in output positions,
at the declaration site.
- Inheritance: arity at extends/implements/use, bound conformance with
bound-on-bound for forwarded parameters, diamond detection, parametric
LSP into properties/hooks/trait methods/inherited methods.
- Turbofish at runtime: arity and bounds at every call site that
supplies type arguments.

Forgive me if I'm misunderstanding some of the jargon here, but this all seems fairly inconsequential compared to actually enforcing the generic type itself.

Given this:

class Foo<T: int|string> { function test(T $in) { ... } }

I think you're saying that I can't write one of these:

$foo = new Foo::<MySpecialNumberObject>;
class Bar<T: MySpecialNumberObject> extends Foo<T> { ... }

But I don't need to, because I can just write this anyway:

$foo = new Foo::<int>;
$foo->test(new MySpecialNumberObject);

If I don't run a static analyser, that will run just fine.

And if I *do* run a static analyser, it could check all the funky things about diamonds and self-referential bounds as well. I presume existing implementations already do these checks, even if they're not 100% consistent between tools?

Finally, on the "broken code published to Packagist" concern: someone
can already publish:

/** Map the given array using the provided callable. */
function map(array $array, callable $fn): array {
     return [];
}

Absolutely, comments can lie. Docblock annotations can also lie - and, in my experience, frequently do. But right now, PHP's native syntax does *not* lie - a property marked "private" really is private, a return type marked "int" really is always an integer, a parameter marked "SomeObjectType" will only accept an instance of SomeObjectType, not even a sneaky "null reference"...

This proposal would fundamentally change that - it would introduce syntax which looks like it's part of the standard, enforced, type system; but, in many cases, would do absolutely nothing.

Nobody is calling for PHP to ship a precious type system with support for lifetimes, and const generic parameters to prevent it
(e.g. `function map<'a, I, O, const SIZE: int>(vec<&'a I, SIZE> & ![] $array, fn(&'a I): &'a O $fn): vec<&'a O, SIZE> & ![]`).

No, but if someone proposed to make that valid PHP syntax, which did nothing outside of a third-party tool, I would not expect them to get much support.

I can absolutely see the advantage of standardising third-party generic annotations or attributes; but I want to reserve first-class syntax for features that have first-class enforcement.

Regards,

--
Rowan Tommins
[IMSoP]

On Mon, May 11, 2026, at 11:25 AM, Seifeddine Gmati wrote:

Hi Rowan,

I think the core premise is empirically testable, and the test has
already run: PHP has had generics in docblocks for a decade, used by
every major framework. If "someone publishes a library with broken
type information and downstream users get surprised by runtime
behavior" were a real failure mode, we would see it today, as Laravel,
Doctrine, Symfony, PSL, and PHPUnit all run on `@template`-annotated
code and are consumed by millions of downstream applications. The
failure mode you describe is the one that would occur most under the
current system, yet it doesn't.

The reason it doesn't is that the people who care about generic type
information are the same people who run static analysis. The
intersection of "uses generics" and "doesn't run a static analyzer" is
approximately empty in practice. Native syntax doesn't change that. It
just gives current users a better way to express what they mean.

Regarding the second point, the proposed implementation is enforced
more at compile time than docblocks are. The engine validates:

- Generic parameter declarations: arity cap, bound conformance,
default-vs-bound, no top-level self-reference, no shadowing.
- Variance soundness: Covariant parameters cannot appear in input
positions, contravariant parameters cannot appear in output positions,
at the declaration site.
- Inheritance: arity at extends/implements/use, bound conformance with
bound-on-bound for forwarded parameters, diamond detection, parametric
LSP into properties/hooks/trait methods/inherited methods.
- Turbofish at runtime: arity and bounds at every call site that
supplies type arguments.

Docblocks validate none of this; the engine accepts a docblock that
says anything. Native syntax is a stricter contract than what the
ecosystem has today.

Finally, on the "broken code published to Packagist" concern: someone
can already publish:

/** Map the given array using the provided callable. */
function map(array $array, callable $fn): array {
    return [];
}

The signature claims to take an array and a callable, returns an
array. The body returns an empty array regardless. Today, this passes
every PHP type check, parses without error, runs in production, and is
technically conformant. Nobody is calling for PHP to ship a [precious
type system](https://www.youtube.com/watch?v=Ay-gnCqDw9o) with support
for
[lifetimes](Explicit annotation - Rust By Example),
and [const generic
parameters](Generic parameters - The Rust Reference)
to prevent it (e.g. `function map<'a, I, O, const SIZE: int>(vec<&'a
I, SIZE> & ![] $array, fn(&'a I): &'a O $fn): vec<&'a O, SIZE> &
![]`). The same logic applies to generics: the contract a user writes
is the contract a user is responsible for honoring, and PHP's role is
to provide tools for expressing and checking that contract, not to
make every conceivable mistake impossible.

The options you listed both close off paths the PHP ecosystem has
already shown it doesn't want.

Cheers,
Seifeddine.

Hi Seifeddine.

First off, let me congratulate you on an absolutely superb "stage setting" in this RFC. :slight_smile: The first half (the history and arguments for it) is solid, and I very much appreciate the level of detail it includes.

That said, I tend to agree with Levi. Erased types are a foot-gun.

The options you listed both close off paths the PHP ecosystem has
already shown it doesn't want.

I'm not sure I follow what you're saying here, but it's worth noting that the two points Levi mentioned have never actually been viable, so saying "the ecosystem doesn't want it" isn't justified. I strongly suspect that the associated types work that Gina was (and still is, AFAIK) working on would be well-received and widely used.

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'm also not sure we can conclude that

The intersection of "uses generics" and "doesn't run a static analyzer" is approximately empty in practice.

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. They *should* be, but I've worked on teams that were using Laravel and I had to teach them about PHPStan.

Where this becomes a land-mine is less the production deploys today, but that future improvements become BC breaks. Technically only BC breaks for sloppy code, but we've gotten ample flack in the past for "BC breaks only for sloppy code" (eg, promoting undefined vars/keys to warnings, adding return types to magic methods/interfaces, etc.). My fear (and I don't know how to quantify how justified this fear is) 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.

I wouldn't want to have a generics-version of #[ReturnTypeWillChange].

A firm and explicitly documented agreement that "if your types are wrong, that's not covered by BC, so we don't care if it breaks on you later" would be an option; not a popular option, perhaps, but an option. :slight_smile:

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. Probably not as robust or pedantic as PHPStan/Psalm/Mago, but at the very least an enhanced alternative to the existing lint support, to catch things like type mismatches, generics errors, etc. Yes, I realize this opens up a whole other can of worms, but generics always opens a can of worms.

I think the associated types enforcement could absolutely be done at the same time as the erased-for-callsite part. But if we do erased, does that make adding associated types (basically "declaration enforcement") in the future harder? Either technically or politically? If so, then that's a problem. If we set up the processes (both technical and political) to ensure that it is not harder to add, then we're in a much better position.

So for me to vote in favor of this RFC, we would need to have *some* degree of first-party enforcement, if for no other reason so that we can improve that enforcement (whether runtime or otherwise) in the future, bit by bit. "PHP ships with what you need to write valid PHP" is a good rule to have. We can (and assuredly will) debate what that means in practice.

As to the RFC details itself:

Syntax notes:

- 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. As Bob notes, this can be done without completely claiming the "in" and "out" keywords.

- An interesting quirk I found in my previous research[1] 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 :. That's longer, though. I'm not sure how I feel here; we certainly don't have to use the same keyword in both places, but it could be expected. I mention it mainly so that we can have that discussion and make a deliberate, informed decision.

Other notes:

- I am completely OK with skipping typed arrays at this point. In practice I'd rather build objects with nice operator overrides directly into the stdlib. (See my previous investigation[2] on the subject.)

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

- What would a turbofish on a static call look like? self::<Bar>foo(1) or self::::<Bar>foo(1)? The RFC should specify.

- I'd be completely OK with lowering the argument cap from 127, too. Like, if you have more than 4 then you're probably doing something very wrong already. :slight_smile: Reserving more space there seems fine.

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

- The "What is/isn't enforced" section could really use examples. A lot of it is hard for me to follow as it's so abstract. (And thus determine if it's "enough" enforcement for me to be able to support it.) Same for the Limitations section. Examples please.

- 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. (IE, I'd normally expect that term to mean Python-style "they may as well be comments" types.) I would recommend raising the "what is enforced" section, or some junior version of it, way up to the top to help set expectations, because this is an interesting semi-enforced hybrid. I am still unsure if it's enforced-enough, but I think calling it "erased" (and most people won't automatically grok what "bound erased means") is doing it a disservice. 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?

- I recommend making a bigger deal of the fact that the turbofish being optional is a BC layer. Having the absence of it default to mixed is a *huge* deal for making generics adoption smoother. This is an attribute that should get a lot more attention than the RFC currently gives it.

- 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?

- I don't see an example of how you'd write "the return value is of the class specified by this string parameter." That's one of the status quo examples you show, and one that I use rather frequently. Please include an example of that, or note that it is not supported and what we should do instead.

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:

Thanks, Seifeddine!

--Larry Garfield

[1] php-rfcs/generics/use-cases.md at master · Crell/php-rfcs · GitHub
[2] php-rfcs/collections/research-notes.md at master · Crell/php-rfcs · GitHub

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.

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.

So for me to vote in favor of this RFC, we would need to have *some* degree of first-party enforcement

I think the RFC already provides substantial first-party enforcement,
more than the "erased" framing suggests. The engine validates arity at
declaration and inheritance sites, bound conformance including
bound-on-bound for forwarded parameters, variance soundness at
declaration positions, parametric LSP into substituted method
signatures, default-vs-bound conformance, the 127-arg cap, top-level
self-reference and shadowing, and arity+bounds at every turbofish call
site. The compile-time and link-time enforcement surface is comparable
to Java's. What's not enforced is parametricity at call sites, which
is the "erased" part. If the concern is "PHP should validate generic
code itself, not require external tools," then this RFC does that, for
everything the runtime can check without reified types.

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.

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 am completely OK with skipping typed arrays at this point. In practice I'd rather build objects with nice operator overrides directly into the stdlib.

Typed array deserve their own RFC. My initial draft did include
`array<K, V>`, but without enforcing arity or bounds it's effectively
useless, the array-key coercion problem makes this:

function x(array<string, string> $a, string $k, string $v):
array<string, string> {
    $a[$k] = $v;
    return $a;
}

potentially return an `array<string|int, string>` if `$k` is a numeric
string. Properly typed arrays need separate, more careful design than
this RFC can give them.

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)

What would a turbofish on a static call look like? self::<Bar>foo(1) or self::::<Bar>foo(1)? The RFC should specify.

Neither. the syntax is `self::foo::<Bar>(1)`. The turbofish always
comes after the method name and before the argument list, consistent
with how it's used in Rust. I'll make this explicit in the RFC.

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.

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.

The "What is/isn't enforced" section could really use examples. A lot of it is hard for me to follow as it's so abstract. Same for the Limitations section. Examples please.

fair. The PR's test suite has all the relevant behaviors covered, so
I'll extract examples from there into both sections.

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.

I recommend making a bigger deal of the fact that the turbofish being optional is a BC layer. Having the absence of it default to mixed is a *huge* deal for making generics adoption smoother.

True, this deserves more visibility. The whole point is to make
adoption smooth: existing code doesn't need to be annotated all at
once (or at all, since SA tools can already infer most of it), and
incremental migration just works. I'll elevate this in the RFC.

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.

Hi Ben

Apologies to Seifeddine. I forgot they maintain Mago. :slight_smile:

But my question still stands for PHPStan, Psalm, and others.

We have endorsement from Matt Brown, creator of Psalm and the
static-analysis author most responsible for putting function-level
`@template` into production PHP. (Phan shipped class-level generics
first; the function-generics work that the rest of the ecosystem
followed came through Psalm.)

Regarding PHPStan, I contacted Ondřej to confirm that everything in
the RFC works well from their perspective. I think it does, but
verification is still in progress.

Cheers,
Seifeddine.

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...

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.

I think it's possible to separate "people who want static analysis" from "people who want the type system to be efficiently enforced". And it would be perfectly reasonable to maintain separate tools for those separate audiences.

Take Java, for instance: every Java compiler will automatically complain about basic type violations; but there are still plenty of third-party Java static analysis tools. I don't think that indicates a flaw in the compiler design; it's just different tools for different audiences.

In the same way, a bundled tool that statically enforced generic types *and nothing else* would still have value - particularly if it was somehow mandatory, like a pre-processing step.

--
Rowan Tommins
[IMSoP]