[RFC] [Discussion] Bound-Erased Generic Types

php.internals

Seifeddine Gmati

113 days ago
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 - Implementation: https://github.com/php/php-src/pull/21969 Thanks, Seifeddine.

Bob Weinand

113 days ago
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 > - Implementation: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>, 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

Seifeddine Gmati

113 days ago
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: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: 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>, 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 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. 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: 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.). 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.

Bob Weinand

113 days ago
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. ( > refhttps://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 > > 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

Seifeddine Gmati

113 days ago
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 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 > > 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: https://github.com/php/php-src/compare/9ebcf28cef5563a63fe0bcc2fd5ec45211fa1f15..f8ced4dacacda2038118bcc889f4905a92cf05de 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: https://github.com/php/php-src/compare/9ebcf28cef5563a63fe0bcc2fd5ec45211fa1f15..f8ced4dacacda2038118bcc889f4905a92cf05de 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: https://github.com/php/php-src/compare/f8ced4dacacda2038118bcc889f4905a92cf05de..6b5588d8e927d60e4b4509658af62601dfa0802a 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.

Seifeddine Gmati

113 days ago
> > 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 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 > > > > 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: https://github.com/php/php-src/compare/9ebcf28cef5563a63fe0bcc2fd5ec45211fa1f15..f8ced4dacacda2038118bcc889f4905a92cf05de > > 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: https://github.com/php/php-src/compare/9ebcf28cef5563a63fe0bcc2fd5ec45211fa1f15..f8ced4dacacda2038118bcc889f4905a92cf05de > > 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: https://github.com/php/php-src/compare/f8ced4dacacda2038118bcc889f4905a92cf05de..6b5588d8e927d60e4b4509658af62601dfa0802a > > 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: https://wiki.php.net/rfc/bound_erased_generic_types#diamond_inheritance 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 :D Cheers, Seifeddine.

Levi Morrison

113 days ago
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: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969 > > 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

Seifeddine Gmati

113 days ago
> > 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: https://wiki.php.net/rfc/bound_erased_generic_types > > - Implementation: https://github.com/php/php-src/pull/21969 > > > > 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: https://wiki.php.net/rfc/bound_erased_generic_types#the_127-argument_cap 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.

Brent Roose

113 days ago
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:

Ben Ramsey

113 days ago
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]: https://thephp.foundation/blog/2025/08/05/compile-generics/

Ben Ramsey

113 days ago
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. :-) But my question still stands for PHPStan, Psalm, and others. Cheers, Ben

Seifeddine Gmati

112 days ago
Hi Ben
> Apologies to Seifeddine. I forgot they maintain Mago. :-) > > 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.

Rowan Tommins [IMSoP]

113 days ago
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]

Seifeddine Gmati

113 days ago
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](https://doc.rust-lang.org/rust-by-example/scope/lifetime/explicit.html), and [const generic parameters](https://doc.rust-lang.org/reference/items/generics.html#r-items.generics.const.usage) 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.

Rowan Tommins [IMSoP]

113 days ago
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]

Larry Garfield

112 days ago
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](https://doc.rust-lang.org/rust-by-example/scope/lifetime/explicit.html), > and [const generic > parameters](https://doc.rust-lang.org/reference/items/generics.html#r-items.generics.const.usage) > 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. :-) 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. :-) 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. :-) - 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. :-) 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. :-) Thanks, Seifeddine! --Larry Garfield [1] https://github.com/Crell/php-rfcs/blob/master/generics/use-cases.md [2] https://github.com/Crell/php-rfcs/blob/master/collections/research-notes.md

Seifeddine Gmati

112 days ago
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 https://wiki.php.net/rfc/bound_erased_generic_types#future_scope. 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 (https://docs.hhvm.com/hack/reified-generics/reified-generics-migration/). 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 https://carthage-software.github.io/suffete/universe/elements.html), 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: ```php 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. :-)
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. :-)
Hopefully the above moves you a bit further along. Please let me know if you have more questions or concerns. Cheers, Seifeddine.

Rowan Tommins [IMSoP]

112 days ago
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 (https://docs.hhvm.com/hack/reified-generics/reified-generics-migration/). >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 >https://carthage-software.github.io/suffete/universe/elements.html), >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]

Bob Weinand

112 days ago
On 12.5.2026 00:07:19, Rowan Tommins [IMSoP] wrote:
> On 11 May 2026 22:17:22 BST, Seifeddine Gmati > <azjezz@carthage.software> wrote: >> The principle: as long as we don't change the semantics of existing >> syntax, no future improvement introduces a BC break. Reified generics, >> for example, can be added later as opt-in, just like how HackLang did >> it >> (https://docs.hhvm.com/hack/reified-generics/reified-generics-migration/). >> A class or function would need to declare `#[ReifiedGenerics]` (or >> similar) to opt into reified semantics; everything else continues to >> behave exactly as the bound-erased model specifies. Library and >> framework authors then choose the strictness-vs-performance tradeoff >> that fits their use case, and existing code never breaks because the >> default behavior never changes. > > > This feels like a cop-out to me. Imagine we had done the same for > visibility keywords: "private" was a reserved word, but the engine > didn't enforce it, it was just there for static analysers; then later, > we added an opt-in attribute #[EnforceVisibility]. Either everyone > would use it and whinge about it not being the default; or nobody > would use it, and whinge about the language having a broken visibility > system. > > Or imagine if we implemented scalar type declarations, but couldn't > decide whether to make them fully-static or auto-coercing, so we > implemented a switch that had to be set at the call-site, and was > widely misunderstood ... oh, wait, we did that one...
I totally agree that an attribute meaningfully altering runtime behaviour (unlike the current ones which are mostly simple checks) is bonkers. If we ever switch to reified generics (i.e. particularly on runtime values), we need to seriously enforce it. Everywhere. This is - yes - a BC break. But should only be a break for code which does already forbidden things. We can document very clearly that non-enforcement is not an excuse for not following the type checks. And just switch. We could also opt for having a deprecation phase where mismatching types are allowed, but emit E_DEPRECATED. Maybe with an INI (to be removed a couple versions later). At least library code is generally to much higher standards than application code anyway. Not sure if it's the best way, but surely solutions can be found. I don't know if this is necessarily a blocker. It's not optimal, but maybe okay. Bob

Seifeddine Gmati

112 days ago
Hi Bob,
> I totally agree that an attribute meaningfully altering runtime > behaviour (unlike the current ones which are mostly simple checks) is > bonkers.
The "attribute changing runtime behavior is bonkers" concern is fair, and the principle of opt-in doesn't require the attribute spelling specifically. A keyword (class A<reified T>), or a separate declaration form all carry the same semantic without putting runtime-altering metadata in the attribute system. The spelling is open.
> We could also opt for having a deprecation phase where > mismatching types are allowed, but emit E_DEPRECATED.
That path could be under consideration, but I think the opt-in path is structurally better, for two reasons beyond BC. First, reification isn't free even when it's correct. The library author who SA-verifies their generic types and ships clean code pays a runtime cost for the application developer who didn't run SA. Universal reification transfers cost from sloppy code to careful code, which is the wrong direction. Second, the Hack designers had this exact choice (their codebase is internal, they could have flipped semantics on themselves) and chose opt-in. They went erased-by-default with reify as an opt-in keyword (https://docs.hhvm.com/hack/reified-generics/reified-generics/). The reason is the same cost-transfer concern: most generic code at Facebook or Slack scale is type-safe via SA, and forcing it to incur reification costs would slow the whole codebase for the benefit of a small percentage of code that genuinely needs runtime checking. All that said: I appreciate you flagging this isn't a blocker. The opt-in path is what I think the right design is; if reified generics ever ship, the spelling debate happens in that RFC, not this one Cheers, Seifeddine.

Larry Garfield

112 days ago
On Mon, May 11, 2026, at 4:17 PM, Seifeddine Gmati wrote:
> Hi Larry, > > Thank you for your review. > >> As you note, this RFC is a little more than erased generics; it does provide a little validation, and reflection support (in addition to a standardized syntax that's much more understandable than the current de facto standard docblock syntax). That's not nothing, and is a marginal improvement over the status quo. The question is whether that is enough of a benefit, and what future improvements it makes easier/harder. > > I'd argue this RFC makes future improvements strictly easier, not > harder. see https://wiki.php.net/rfc/bound_erased_generic_types#future_scope. > 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 (https://docs.hhvm.com/hack/reified-generics/reified-generics-migration/). > A class or function would need to declare `#[ReifiedGenerics]` (or > similar) to opt into reified semantics; everything else continues to > behave exactly as the bound-erased model specifies. Library and > framework authors then choose the strictness-vs-performance tradeoff > that fits their use case, and existing code never breaks because the > default behavior never changes. > > So the "BC only for sloppy code" risk you describe doesn't > materialize, sloppy code today stays sloppy tomorrow at exactly the > same level.
My concern is something like this: function foo<T>(T $val): bool {} // With this RFC, this will compile and run, and maybe error inside foo() in oddball ways, or not. foo::<int>('beep'); Because the type param isn't enforced. So someone is going to write code that bad, guaranteed. (This is PHP, after all.) Now fast forward a few years, and we figure out a way to performantly enforce that check at runtime and turn that function call into a call-site TypeError. I would consider that a good improvement to the language. However, it would also mean that the previous mismatched line would now generate an error where it didn't before. And the author is going to get up in arms about how "PHP is breaking my code and destroying the language why can't they respect BC" and so on and so on, because we've seen that movie several times now. But what I would absolutely not want to see is someone arguing that "well we can't start enforcing that type at runtime, because someone *might* have stupid code." Or, even worse, #[ParamTypeMismatchesThatsOk] as an attribute on the function to opt-out of enforcing it, the way we did for return types on magic methods. I would consider both of those to be Very Bad(tm) outcomes. So the question for me is how do we set it up, both technically and politically, so that if/when we figure out how to enforce that we can do so without creating another "boo hoo you broke my code" round of blog posts, as those are quite bad for PHP's image.
>> Another option, if we want to take the stance that SA is The Solution(tm) to generics, is to ship an actual first-party SA tool with PHP. > > I want to push back on this strongly. speaking as Mago's author for a moment. > > The existing SA tools (PHPStan, Psalm, Mago) aren't just type > checkers. Type checking is part of what they do, but they also handle > a much larger surface: types PHP itself has no notion of > (`positive-int`, `non-empty-lowercase-string`, `class-string<Foo>`, > `list{1, 3, "hello"}`, and 100s more, see > https://carthage-software.github.io/suffete/universe/elements.html), > plus code quality rules, security analysis, dead code detection, and > so on. A first-party tool would need to compete on that whole surface > to be useful, anything narrower would disappoint the audience that > actually wants SA. > > That leaves two options, neither good: > > 1. A weaker tool than what exists. This doesn't serve the audience > that wants real SA, and users will rightly be frustrated that PHP > shipped something less capable than third-party alternatives. > > 2. A tool competitive with existing ones. This is probably a year or > two long C project requiring a dedicated team, on top of the PHP core > team's existing scope. As someone who built a full toolchain in Rust > from scratsh following Psalm/Hakana's footsteps, I can tell you this > is substantially harder than it looks, and it would be *considerably* > harder in our case than it was for me, for two reasons: > > 2.1. Mago is written in Rust, which is a more productive language > for this kind of tool and has the ecosystem to match. Building the > equivalent in C, against PHP's existing engine constraints, means a > much higher cost per feature, per bug fix, per refactor. > > 2.2. Mago isn't part of PHP. If we get variance wrong, we ship a > fix the same day. sometimes twice a day, sometimes once a week. We > aren't bound by the php-src release cycle, and we iterate as soon as > we have a fix. A first-party SA tool inherits PHP's release cadence: > fixed-cadence releases, feature freeze windows, stability guarantees. > For a tool whose value depends on keeping up with framework patterns, > library conventions, and emerging idioms, that release shape is wrong. > > Beyond the engineering cost, there's a parser problem: PHP's current > parser doesn't produce the full CST that an SA tool needs. You'd need > a new parser, a static name resolver, a static reflection system that > doesn't execute files to extract type information, and so on, a > substantial reimplementation of analysis infrastructure in C, which > the community already has high-quality versions of in Rust and PHP. > > In short: shipping a first-party SA tool would be a large, probably > multi-year, investment for a result that's almost certainly worse than > the tools the community has already built. I don't think it's a good > use of PHP's resources, and I'd argue strongly against it.
In general, several of those issues are entirely self-created, and PHP can easily resolve them. 1. Nothing says we can't provide a first-party SA tool that's written in Rust instead of C. 2. Nothing says a first-party SA tool must follow the exact same cadence as the engine. Making improvements to it at the same time as a .z release of the engine is completely reasonable; it would be purely an administrative decision to do that or not. First-party doesn't have to mean "in the php-src C code directories." Though leveraging some parts of those as externs could certainly make sense. (Joking: Should we just ship Mago with PHP? :-) )
>> I agree with Bob that the +/- syntax is very confusing. I would much rather use in/out, as seen in Kotlin and C#, which are both vastly more self-documenting and match what some of our peer languages are doing. > > No strong preference here. I went with `+`/`-` because that's what > HackLang uses and it's familiar to me, but `in`/`out` is fine if > that's the consensus. Happy to switch if enough people prefer the > keyword form.
Kotlin and C# have several orders of magnitude more users, so the "familiarity" argument goes firmly in that direction.
>> An interesting quirk I found in my previous research is that languages that use : for inheritance use : for bounding generics. Languages that use "extends" for inheritance also use "extends" for bounding generics. PHP uses "extends", so that pattern would suggest we use "class Box<T extends FooBar>" rather than :. > > I'd disagree on this one. `class A<T extends C> extends B` reads > awkwardly to me, especially when the bound is scalar or a union eg., > `class A<T extends int|string> extends B`, is rough to parse visually. > The colon form keeps the type-parameter bound visually distinct from > the class's own extension relationship, which I think is the more > important consistency to optimize for.
I don't disagree with you here. As I said, it's more that I want to bring it up and make sure it's an explicit decision to use a different signifier than everyone else.
>> I fully expect "turbofish" to result in all kinds of slide shenanigans at conferences. At least on my slides. :-) > > It hasn't been a problem in Rust, and I don't think it will be in PHP > either. (Looking forward to the slides though)
At what point did I say slide shenanigans are a problem? :-)
>> I'd be completely OK with lowering the argument cap from 127, too. > > that makes sense in principle, but assuming you mean the > actual-parameter cap rather than the type-parameter cap, that's a > separate RFC and would itself be a BC break. Worth doing eventually, > just not as part of this one.
No no, I meant the type argument cap. function foo<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>(/* */) {} That's 16 type arguments, an order of magnitude less than the cap, and I'll already say any such code should be rejected on sight as bonkers. So reserving more than one bit for future flags seems completely fine to me, and perhaps advisable.
>> Why is ReflectionGenericVariance backed? I don't see what the ints add. > > It's backed because the values match what the engine uses internally. > no strong preference though, i can switch it to a unit enum if that > reads better.
I'd probably omit it, but if it's kept, the RFC should at least include that explanation.
>> OK, having read the full RFC, it seems to be sort of "mostly-erased" generics. There is notably more enforcement than the term "erased" would imply. [...] It looks like, in practice, basically everything on the declaration side is enforced; it's just the call side that is unenforced. Is that an approximately accurate summary? > > Approximately, yes, declaration-side enforcement is substantial, > call-side type arguments are erased. The proposal is closer to Java's > model than Python's, but I want to keep the "erased" terminology > because the defining property, type arguments not available at > runtime, is what distinguishes this from reified generics. > "Bound-erased" is the precise term: bounds are enforced where they can > be (declaration sites, substituted method signatures), type arguments > themselves are erased at use sites. That said, your point about > expectations is fair, I'll consider moving the enforcement section > higher in the RFC to set those expectations earlier.
And explicitly calling out something like "despite the name, this is mostly-enforced generics." Or something like that, which seems more accurate. Would this be enforceable? class Collection<T> { public function add(T $val): void {} } $c = new Collection<int>(); $c->add('string'); // Error, or would this be allowed at runtime? My gut sense is that is the most common place where it would come up; function/method generics are going to be a lot less common than class generics, so that would be the place to ensure we get as much enforcement as we can.
>> If I understand correctly, this RFC would allow for foo(Collection<int> $c) {}, but wouldn't actually enforce it at runtime. Within the function, `instanceof Collection` would still work, but there's no `instanceof Collection<int>` alternative. Am I reading that correctly? > > yes. that's exactly the erasure behavior. the type argument isn't > available at runtime, so `instanceof Collection<int>` is the same as > `instanceof Collection`. If you pass a `Collection<string>` to a > function expecting `Collection<int>`, runtime accepts it because both > are `Collection` at runtime; the mismatch is caught by SA, not by the > engine. > >> I think at this point I am still skeptical, but warming to it, and could be convinced. But more convincing is needed. And lunch, which I think I need after reading all of this. :-) > > Hopefully the above moves you a bit further along. Please let me know > if you have more questions or concerns. > > Cheers, > Seifeddine.
Two additional questions: - With reflection, would it be possible to tell what a given object was instantiated with, or only the class? new ReflectionObject($listOfInts)->getTypeParam() => ReflectionType(int)? Or is that also the erased part? - I assume that dynamically specifying the type parameter is also right-out, yes? function make(string $className, int $count) { $c = new Foo<$className>(); foreach ($count as $i) { $c->add(new $className()); } return $c; } --Larry Garfield

Seifeddine Gmati

112 days ago
> Now fast forward a few years, and we figure out a way to performantly enforce that check at runtime and turn that function call into a call-site TypeError. I would consider that a good improvement to the language. However, it would also mean that the previous mismatched line would now generate an error where it didn't before. And the author is going to get up in arms about how "PHP is breaking my code and destroying the language why can't they respect BC" and so on and so on, because we've seen that movie several times now.
This depends on how reified generics are implemented, specifically if it's the way you suggest. I.e, all erased generics become reified, then this is a problem. However, if we make reified generics an opt-in feature, this is not a concern, because the code will keep working as is, and would only break if the author of `foo` enables reified generics for it. Personally, if i had the option of full-reified generics from day one, or bound-eraser + opt-in reification, the latter makes more sense. Because I don't want to pay for the performance penalty that will come with reified generics, when I know that all my code is type-safe. Quoting Matt Brown: "I have now been writing Hack (Facebook fork of PHP) full-time for almost five years — alongside hundreds of other backend engineers at Slack — and it’s obvious to everyone around me that erased generics are a good idea."
> In general, several of those issues are entirely self-created, and PHP can easily resolve them. > > 1. Nothing says we can't provide a first-party SA tool that's written in Rust instead of C. > > 2. Nothing says a first-party SA tool must follow the exact same cadence as the engine. Making improvements to it at the same time as a .z release of the engine is completely reasonable; it would be purely an administrative decision to do that or not. > > First-party doesn't have to mean "in the php-src C code directories." Though leveraging some parts of those as externs could certainly make sense.
1. If it's written in Rust, C++, Zig, whatever, it might make the work easier, but it won't make it instant. 2. True, but again, this needs a maintenance team, a spec team, its own documentation, and more.
> (Joking: Should we just ship Mago with PHP? :-) )
Mago could help if such an SA were written in Rust, because it can provide the parser, name resolver, reflections, semantics analyzer, and a few other crates. However, the analyzer can't be used as is, because it does much more than just "check if generics are being used correctly". However, this would open a can of worms: Should this analyzer support watch mode? Should it perform incremental analysis? What about the language server? Should it offer IDE/editor integration? and a million other things. ( Also note: PHP *can't* actually use the Mago parser and get away with it, because in Mago, we made a decision a long time ago not to support non-utf-8 code. PHP must stay true to the engine, so it must re-write its own parser ).
> Kotlin and C# have several orders of magnitude more users, so the "familiarity" argument goes firmly in that direction.
We will see, this small change can be made later, before the vote, or perhaps require a secondary vote.
> At what point did I say slide shenanigans are a problem? :-)
Nvm, read it wrong :)
> No no, I meant the type argument cap. > > function foo<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>(/* */) {} > > That's 16 type arguments, an order of magnitude less than the cap, and I'll already say any such code should be rejected on sight as bonkers. So reserving more than one bit for future flags seems completely fine to me, and perhaps advisable.
Ah, I agree. Already changed the cap to 127.
> I'd probably omit it, but if it's kept, the RFC should at least include that explanation.
I will omit it.
> And explicitly calling out something like "despite the name, this is mostly-enforced generics." Or something like that, which seems more accurate. > > Would this be enforceable? > > class Collection<T> { > public function add(T $val): void {} > } > > $c = new Collection<int>(); > > $c->add('string'); // Error, or would this be allowed at runtime?
No, hence the name "bound erased." In this example, `T` has no bound, therefore, it is the same as if it were bound to `mixed`, so this won't error. However, the following applies: ``` class Collection<T> { public function add(T $val): void {} } class UserCollection extends Collection<User> {} $c = new UserCollection(); $c->add(new User()); // ok $c->add("hello"); // TypeError ``` because `T` gets substituted with `User` at compile time.
> - With reflection, would it be possible to tell what a given object was instantiated with, or only the class? new ReflectionObject($listOfInts)->getTypeParam() => ReflectionType(int)? Or is that also the erased part?
No, that's erased. This is entering reified generics territory. and causes associated performance issues. Either we add an extra pointer field to `zend_object` ( +8 bytes on every PHP object, regardless of whether it uses generics or not ), or we keep a side table keyed by object handle. The latter moves the cost from storage to lookup (every read incurs a hash table hit, and the table must be torn down on `__destruct` ). Neither is free. Beyond storage, the bindings themselves aren't cheap to carry either: each `zend_type` is 16 bytes plus any list / named-with-args payload it points to. Therefore, something like `Map<string, Box<User>>` involves multiple type structures per instance, all of which must be kept alive, reference-counted, and traversed at instanceof / reflection time. And once the runtime can read the binding back, the pressure to actually use it follows immediately: $obj instanceof Box<string> becoming truthful, parameter checks tightening from the bound to the substituted type, etc. That's the full reified runtime, which is a separate RFC (and one we explicitly punt on in "Why bound erasure").
> - I assume that dynamically specifying the type parameter is also right-out, yes?
Yep, not supported, and I don't see it being useful at all really. the way to type that function properly would be something like this: ``` function make<T: object>(int $count) { $c = new Foo::<T>(); foreach ($count as $i) { $c->add(new T()); } return $c; } ``` Note: This requires reified generics, specifically, the `new T` part.

Rowan Tommins [IMSoP]

112 days ago
On 12 May 2026 00:51:08 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:
>Quoting Matt Brown: "I have now been writing Hack (Facebook fork of >PHP) full-time for almost five years — alongside hundreds of other >backend engineers at Slack — and it’s obvious to everyone around me >that erased generics are a good idea."
Just to bang this drum one more time, because I haven't seen you acknowledge it: Hack ships with a tool to statically enforce generic types before erasing them. <https://docs.hhvm.com/hack/getting-started/tools/>
> hh_client: this is the command line interface for Hack's static analysis; it is needed to verify that a project is valid Hack, and is used to find errors in your programs
When people talk about their experience with erased generics in Hack, they're talking about their experience of universal use of that tool. I don't think you can apply that experience to a situation where the majority of users of the language have no such tool. Rowan Tommins [IMSoP]

Seifeddine Gmati

112 days ago
Hi Rowan,
> When people talk about their experience with erased generics in Hack, they're talking about their experience of universal use of that tool. > > I don't think you can apply that experience to a situation where the majority of users of the language have no such tool.
`hh_client` isn't structurally different from PHPStan, Psalm, or Mago. You can run `hhvm bad_file.hack` without ever invoking it, and Hack has third-party static-analysis too ( Hakana, for example: https://github.com/slackhq/hakana). The only difference is that hh_client ships in the same tarball as the runtime instead of in a Composer package. The more important point is who's actually in the population the Hack experience describes. Matt's quote is about Slack engineers, and Slack adopted Hack because they wanted static analysis. The people whose experience he's describing are those who chose to engage with the tool, exactly the same population as PHP developers who choose to run PHPStan or Psalm or Mago or Phan today. Bundling changes where the tool originates doesn't change who reaches for it. The audience for native generics is the same audience that already uses docblock generics, which is the same audience that already runs SA. That audience exists in PHP today, voluntarily, without bundling. Their experience of erased generics in PHP will match Slack's experience of erased generics in Hack, because they're the same kind of user with the same tooling discipline. Cheers, Seifeddine.

Rowan Tommins [IMSoP]

112 days ago
On 12 May 2026 13:01:44 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:
> >The audience for native generics is the same audience that already >uses docblock generics, which is the same audience that already runs >SA.
This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption. As soon as PHP ships native syntax for generics, an entirely new set of users will hear about it and try to use it. Their experience is going to be shaped by what *PHP itself* does with that new syntax. I would also argue that the flipside applies: if the audience for the feature really is existing users of third-party SA tools, then it shouldn't be shipped as part of php-src. The language should only provide the building blocks for those tools to work with - and attributes seem perfectly suited here. It's not the job of this list to create standards for what attributes third-party tools support. Rowan Tommins [IMSoP]

David Gebler

113 days ago
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.

shinji igarashi

112 days ago
Hi!
> I choose that word carefully: it is not type erasure per se that I am against, but the ability to write code which *looks like* it enforces particular signatures, but actually does not.
If one of the concerns is that `Collection<int>` looks like something that will be checked at runtime, but actually will not be, would it make sense to introduce erased generics together with syntax that explicitly marks the type declaration as erased/unchecked? For example: ``` function foo(erased Collection<int> $c): void {} ``` If the keyword is considered too verbose, a symbolic marker could also be considered: ``` function foo(@Collection<int> $c): void {} ``` or: ``` function foo(-Collection<int> $c): void {} ``` The exact spelling is obviously bikeshedding. My point is more about where the marker should be placed semantically: perhaps the marker should be on the erased/unchecked form, rather than on a future reified form. In the current discussion, one possible answer to future reified generics is that they could be introduced as an opt-in feature later. However, if the disagreement is about the expectation that PHP type declarations are enforced at runtime by default, then it seems more consistent to mark the erased form explicitly. That would give users three distinct forms: ``` Collection // runtime class check only // no claim about type arguments; raw/unparameterized/unknown @Collection<User> // erased generic annotation // at runtime, only Collection is checked // User is preserved for static analysis and Reflection // the marker could be a keyword such as `erased`, or some symbol; // the exact spelling is not the important part Collection<User> // left available for a future reified / runtime-checked meaning ``` This would avoid making the most natural spelling, `Collection<User>`, mean “looks checked, but is actually erased”. Users who primarily want static analysis support could still opt into the erased form explicitly, while the unmarked syntax would remain available for a future runtime-checked model. Thanks!
-- Shinji Igarashi 2026年5月11日(月) 21:12 Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk>:

Barel

113 days ago
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. > > - RFC: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969 > > 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

Matthew Brown

112 days ago
> On May 12, 2026, at 3:07 pm, Seifeddine Gmati <azjezz@carthage.software> wrote: > > Hello Internals, > > I'd like to start the discussion on a new RFC adding bound-erased > generics types to PHP. > > Generic type parameters can be declared on classes, interfaces, > traits, functions, methods, closures, and arrow functions, with > bounds, defaults, and variance markers. Type parameters erase to their > bound at runtime; the pre-erasure form is preserved for Reflection and > consumed by static analyzers. > > - RFC: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969 > > Thanks, > Seifeddine.
I think this is a good proposal and mirrors the various Python PEPs that added erased type hints in Python 3. Unlike TypeScript or Hack, Python has no official typechecker — though MyPy is a de facto standard, but it doesn’t ship alongside Python. Those PEPs have spawned a healthy ecosystem of typecheckers (MyPy, Pyright, Pyre/Pyrefly, Pytype, ty, Zuban). Those typecheckers sometimes take different approaches to the same code (e.g. https://pyrefly.org/blog/container-inference-comparison/) but that seems healthy to me. I’ve been writing Hack for almost five years, and while it’s true that Hack ships with its own typechecker, I’ve also been able to build a new typechecker for Hack (only reusing the parser) in the span of about 6 months. When you don’t have to worry about docblock parsing a lot of things become quite a bit easier. This proposal would target a future version of PHP and would realistically only start to seep into code in 2027. I’m very confident that the average person writing PHP code in 2030 will prefer this proposal to using docblock types. Best wishes, Matt

Ondřej Mirtes

112 days ago
On 10. 5. 2026 at 21:02:32, Seifeddine Gmati <azjezz@carthage.software> wrote:
> Hello Internals, > > I'd like to start the discussion on a new RFC adding bound-erased > generics types to PHP. > > Generic type parameters can be declared on classes, interfaces, > traits, functions, methods, closures, and arrow functions, with > bounds, defaults, and variance markers. Type parameters erase to their > bound at runtime; the pre-erasure form is preserved for Reflection and > consumed by static analyzers. > > - RFC: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969 > > Thanks, > Seifeddine. >
Hi, PHPStan creator and maintainer here. I like this RFC, especially the depth at which it explains why a substantial portion of PHP developers would welcome this addition to the language. Some people here and on other channels voiced their concerns about types not being enforced at runtime. My view is that this RFC is for an audience that has been experiencing this every day already while developing in PHP for many years. Because the PHP type system is currently insufficient in expressing types, we use generics in PHPDocs for this job. And they don’t mean anything for the runtime. But we make sure to run static analysers to check our code. This RFC will make the experience of developing in PHP nicer for this audience. I’d compare it to Constructor Property Promotion RFC which reduced the need to mention the same property name in code from 4 to 1. Today if we want to accept a generic object in a parameter, we have to write it like this: /** * @param Collection<Foo> $collection */ function doFoo(Collection $collection): void { } So the parameter name and the class name have to be repeated. In this RFC the code above instead becomes this: function doFoo(Collection<Foo> $collection): void { } Without making things worse for anyone. But making it better for the audience who has been using generics with no runtime enforcement for many years. Which version would you rather write? Prior art in other languages is the proof this makes sense. Let’s say this RFC passes and is released in the next PHP version. If I don’t use static analysers today and a library I depend on introduces native generics, nothing changes for me. Same way I violated @template PHPDocs before, now I’m violating native generics instead. If someone uses @template PHPDocs today in their own code, you can assume they almost certainly also runs a static analyser. No one is forcing anyone to adopt all language features. Same way the match expression isn’t for everyone, bound-erased generic types don’t have to be for everyone. But there are many PHP developers who would welcome this and have been beating the drum for many years. The PHP documentation about this feature could mention the compromises needed to deliver this anticipated feature. And if someone misuses that (introduces generics in their code without checking with static analyser), well, that's a bug they've introduced — the same class of bug already possible today with @template. Ondřej Mirtes

David Gebler

112 days ago
On Tue, May 12, 2026 at 5:20 PM Ondřej Mirtes <ondrej@mirtes.cz> wrote:
> On 10. 5. 2026 at 21:02:32, Seifeddine Gmati <azjezz@carthage.software> > wrote: > >> Hello Internals, >> >> I'd like to start the discussion on a new RFC adding bound-erased >> generics types to PHP. >> >> Generic type parameters can be declared on classes, interfaces, >> traits, functions, methods, closures, and arrow functions, with >> bounds, defaults, and variance markers. Type parameters erase to their >> bound at runtime; the pre-erasure form is preserved for Reflection and >> consumed by static analyzers. >> >> - RFC: https://wiki.php.net/rfc/bound_erased_generic_types >> - Implementation: https://github.com/php/php-src/pull/21969 >> >> Thanks, >> Seifeddine. >> > > Hi, PHPStan creator and maintainer here. I like this RFC, especially the > depth at which it explains why a substantial portion of PHP developers > would welcome this addition to the language. > > Some people here and on other channels voiced their concerns about types > not being enforced at runtime. My view is that this RFC is for an audience > that has been experiencing this every day already while developing in PHP > for many years. Because the PHP type system is currently insufficient in > expressing types, we use generics in PHPDocs for this job. And they don’t > mean anything for the runtime. But we make sure to run static analysers to > check our code. > > This RFC will make the experience of developing in PHP nicer for this > audience. I’d compare it to Constructor Property Promotion RFC which > reduced the need to mention the same property name in code from 4 to 1. > > Today if we want to accept a generic object in a parameter, we have to > write it like this: > > /** > * @param Collection<Foo> $collection > */ > function doFoo(Collection $collection): void > { > } > > So the parameter name and the class name have to be repeated. > > In this RFC the code above instead becomes this: > > function doFoo(Collection<Foo> $collection): void > { > } >
I think my biggest reservation which I'm sure is the same for plenty of others who've read the propsoal is that I can define doFoo there and yet still do $barCollection = new Collection($barList); // Collection<Bar> doFoo($barCollection); And the only way that will be caught is by static analysis. Erased generics in Java are fine, because there is a mandatory compilation process that will check and enforce the types. Erased generics in Python is fine, because there is no type checking *at all* and interpreting the standard syntax for type information is explicitly left to community tooling. PHP is in a weird middle ground. It has no mandatory static check prior to execution but it *does* do runtime type checking, *everywhere else* type declarations are used. But not here, not all the time, if this RFC passes. The very presence of generic types in the language syntax will suggest to users (particularly those who aren't already using annotations, Psalm, PHPStan, etc.) that the syntax *means* something, more than effectively an inline comment, in a way that is not true of Python and I think Rowan's got a solid point about how that reach impacts more than "the people who use SA tools today." But even more confusingly, these type declarations will be partially used in inheritance and reflection. So we can reflect the pre-erasure types, but can't conduct an instanceof check. We can't violate bound generic types via inheritance without a TypeError, but we can otherwise invoke a function with a violating type. So it *looks* like PHP can enforce or at least meaningfully understand Collection<Foo> as a runtime type. But the runtime check is still just Collection. The syntax moves generics out of comments and third party tooling while the validation model will still largely remain in that world. I want to want this, I want this syntax to be part of PHP in a very real way, but given how much members of the PHP community have been beating the drum for generics for years, is it right to deliver a chimera version of it as the long term solution? It's not me who needs to be persuaded in either direction, just food for thought. I like it and I don't at the same time.

Barel

112 days ago
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. > > - RFC: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969 > > Thanks, > Seifeddine. >
Seifeddine, Many thanks for such a detailed and interesting proposal. A couple of suggestions for the RFC: - It would be great to have some mention of the performance cost of this feature, both for code which does not use generics and for code that does use them - It would be great to be able to see how the userland samples from Laravel, Doctrine, etc... would look when using this new syntax, maybe you could add a new section with this Cheers Carlos

Seifeddine Gmati

111 days ago
Hi Carlos, Thanks for the feedback.
> - It would be great to have some mention of the performance cost of this feature, both for code which does not use generics and for code that does use them
The design goal is zero runtime cost for code that doesn't use generics. Code paths that don't go through turbofish should compile to byte-identical bytecode as without the RFC. The verify opcode is only emitted at turbofish sites, so non-generic code never executes it. The current implementation shows around 0.1% benchmark drift in either direction, which is at the noise floor and within the margin you'd expect from any unrelated engine change. I haven't focused on performance optimization yet, the goal at this stage is correctness, not micro-tuning. However, the final number should show zero impact for non-generic code. Code that does use turbofish pays the cost of the arity-and-bound check at the call site, which is dominated by the type comparison itself (the same type comparison the engine does for any typed parameter), not by dispatch overhead.
> - It would be great to be able to see how the userland samples from Laravel, Doctrine, etc... would look when using this new syntax, maybe you could add a new section with this
Agreed. I'll add a parallel section showing what each example looks like in the proposed native syntax, so readers can see the migration directly. Cheers, Seifeddine.

Nick

81 days ago
On 13.05.26 23:26, Seifeddine Gmati wrote:
>> - It would be great to be able to see how the userland samples from Laravel, Doctrine, etc... would look when using this new syntax, maybe you could add a new section with this > Agreed. I'll add a parallel section showing what each example looks > like in the proposed native syntax, so readers can see the migration > directly. > > Cheers, > Seifeddine.
Hey Seifeddine, could you please point me to where the parallel sections for the examples were added? Thanks, Nick

Benjamin Eberlei

111 days ago
Am 10.05.2026, 21:02:32 schrieb Seifeddine Gmati <azjezz@carthage.software>:
> Hello Internals, > > I'd like to start the discussion on a new RFC adding bound-erased > generics types to PHP. > > Generic type parameters can be declared on classes, interfaces, > traits, functions, methods, closures, and arrow functions, with > bounds, defaults, and variance markers. Type parameters erase to their > bound at runtime; the pre-erasure form is preserved for Reflection and > consumed by static analyzers. > > - RFC: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969 > > Thanks, > Seifeddine. >
Hi Seifeddine, I like this proposal very much, I personally don’t think its a problem that some types are erased and some are not. However, I do understand the arguments of others. As a compromise, we could introduce a declare: declare(erased_types=generic_only); If this is not specified, then using generic syntax will throw an exception. And it could give the option in the future to add two modes: declare(erased_types=all); declare(erased_types=none); I’d prefer to land with a version without declare’s, but in this instance I fear we need a temporary solution to get this off the ground until all the pieces are in place. greetings Benjamin

Seifeddine Gmati

111 days ago
Hi Benjamin, Thanks for thinking through this. I'd push back on the declare approach.
> I'd prefer to land with a version without declare's, but in this instance I fear we need a temporary solution to get this off the ground until all the pieces are in place.
The "temporary until all the pieces are in place" framing doesn't quite hold. There's no committed path or timeline for reified generics, no implementation work currently in flight, and no clear answer to whether the runtime cost of reification can be brought to acceptable levels. A declare added on the assumption that it transitions to something else later would, realistically, remain in PHP for the indefinite future. PHP doesn't remove declare directives; `declare(ticks)` remains in the language decades after anyone uses it (at least as far as I'm aware).
> declare(erased_types=generic_only); > > If this is not specified, then using generic syntax will throw an exception.
Requiring `declare(erased_types=...)` to use the syntax raises the adoption barrier rather than lowering it. The point of native generics over docblocks is that the syntax should be easier and more discoverable, not require ceremony at the top of every file. A library exposing generic APIs would need the `declare` in every file. Consumers would copy-paste it as boilerplate, and new users would hit "why isn't my generic syntax working" errors and have to learn about a `declare` they didn't need to know about. `declare(strict_types=1)` has already shown how "opt-in via declare" plays out in practice. It shipped as a per-file choice but the ecosystem turned it into mandatory boilerplate. Linter rules, style guides, and codebase conventions all push toward "every file gets the declare." I have a linter rule that adds it to every PHP file in my projects, including config files that just `return [...];`, because remembering when it does and doesn't matter is mental overhead I'd rather not pay. Most serious PHP teams I know operate the same way. Adding `declare(erased_types=...)` would create the same dynamic within months, except now codebases have two declares to maintain consistency on, and any file using generic syntax errors without it. You'd ship a feature that's nominally opt-in but ecosystem-mandatory, which is the worst of both worlds.
> And it could give the option in the future to add two modes: > > declare(erased_types=all); > declare(erased_types=none);
The granularity is wrong for the future direction you're imagining. If reified generics ever ship, the right opt-in is per-class or per-function, not per-file. The strictness-vs-cost tradeoff is a code-level decision; a single class wanting reified semantics shouldn't require everything else in the file to share that choice. Hack made this exact decision: their `reify` keyword applies per-type-parameter (`function foo<reify T>(T $x)`), not per-file. The granularity matches the design space. `strict_types` is the PHP precedent worth comparing to, but in a different way than your proposal uses it. It's per-file because scalar coercion is a clear binary with a sensible default. Generics doesn't have a clear binary; it has a spectrum (fully erased -> bound erased -> reified -> fully dependent?), and the design space for "what gets enforced at runtime" is still being explored. Locking in a syntactic mechanism now for a feature whose semantics aren't settled would constrain future RFCs unnecessarily. The RFC as written doesn't preclude any of the directions you're imagining. If reified generics ever ships, it'll ship with an opt-in mechanism that fits the granularity of the actual feature, debated and decided at the time it's proposed. That's the right place to make that decision, not pre-loaded into this RFC. Cheers, Seifeddine.

Daniil Gentili

111 days ago
Hi Seifeddine, Thank you so much for making and pushing for this RFC. As the current maintainer of Psalm, I fully support the overall design of the RFC, and I especially support the choice of erased generics, considering the current state of the PHP ecosystem and of php-src itself. Choosing erased generics has two major benefits: - Acknowledging and building upon the currently existing rich ecosystem of static analysers, without significantly changing/breaking existing generic semantics already used by the ecosystem and by the community. - Not locking down semantics for PHP itself by letting future RFCs and implementations do the (very) heavy lifting of runtime validation, using for example monomorphized generics, or i.e. with a static analyzer API, to plug existing static analyzers directly into PHP itself (one of the more promising approaches in my opinion, creating a true TypePHP without rewriting yet another static analyser from scratch, re-using existing, tested and working tools). I will implement support for this RFC in Psalm immediately after the RFC is approved (fingers crossed). I have just a few minor notes after briefly skimming the RFC and the discussion (absolute non-blockers, just a bit of bikeshedding :) 1) Turbofish at callsite: this was already brought up before, while I understand the precedence issues that led to this choice, having written some rust myself, I *still* don’t like the syntax, and would very much prefer normal diamond syntax at callsite, like for declarations. Without diving too deeply into the parser, it seems to me that enabling plain diamond syntax should be easy-ish at least with class instantiation using `new`, even though some of the worst offenders when it comes to readability aren’t class instantiations but rather static calls: ``` [self::foo::<Bar>(1), self::foo::<Bar>(2)] // :( ``` 2) -/+ for variant bounds: also brought up before, while it *can* be somewhat mnemonic (- consumes for input params, + produces for output params), in/out would indeed be much more descriptive IMO (or at the very least, both options could be provided at the same time). 3) More of a wording issue, the RFC describes the new reflection API as the primary way to consume generics, but the main consumers (static analysers) will consume them using nikic/php-parser (as usual, unless another RFC is made to bring proper AST-based parsing infrastructure into PHP itself :), with the new reflection generic API being used only for native/extension generic APIs. Kind regards, Daniil Gentili — Daniil Gentili - Senior software artisan Portfolio: https://daniil.it <https://daniil.it/> Telegram: https://t.me/danogentili

Seifeddine Gmati

110 days ago
Hi Daniil, Thanks for the support. That matters a lot.
> 1) Turbofish at callsite: this was already brought up before, while I understand the precedence issues that led to this choice, having written some rust myself, I *still* don’t like the syntax, and would very much prefer normal diamond syntax at callsite, like for declarations.
I personally like Turbofish; however, I understand that some people might not find it "pretty". But the reason it has to stay as turbofish is parser-level: `[A<B, C>(D)]` is ambiguous between a single-element array calling generic A and a two-element array of comparisons, and there's no context-sensitive disambiguation that works inside attributes, array expressions, and ternaries without introducing rules that bite at exactly the worst places. Forcing parentheses around the call (`[(A<B, C>(D))]`) was suggested earlier in the thread and is uglier than turbofish; it also closes the door on tuple syntax I'd like to keep open for a future RFC. Turbofish costs two characters and keeps the parser non-context-sensitive, which is the trade I'd rather take. The `[self::foo::<Bar>(1), self::foo::<Bar>(2)]` case genuinely looks the worst. The same call without turbofish (`[self::foo(1), self::foo(2)]`) reads fine and is what the vast majority of call sites will look like, since turbofish is opt-in for ambiguity resolution (because inference is hard), and most calls won't need it.
> 2) -/+ for variant bounds: also brought up before, while it *can* be somewhat mnemonic (- consumes for input params, + produces for output params), in/out would indeed be much more descriptive IMO (or at the very least, both options could be provided at the same time).
Secondary vote added on this. `+T`/`-T` vs `in T`/`out T`. I have no strong preference; the vote will decide.
> I will implement support for this RFC in Psalm immediately after the RFC is approved
Side note: I'm optimistic ;) https://github.com/carthage-software/mago/pull/1826 Thanks again for the careful read. Cheers, Seifeddine.

Seifeddine Gmati

110 days ago
> - RFC: https://wiki.php.net/rfc/bound_erased_generic_types
A few updates were made to the RFC today: - Migration diff: full patch showing PSL's Graph package moving from `@template` to native syntax. https://wiki.php.net/rfc/bound_erased_generic_types#migrating_from_docblock_generics - Secondary vote on variance markers: `+T`/`-T` vs `in T`/`out T`. - Performance section: https://wiki.php.net/rfc/bound_erased_generic_types#performance - Enforcement framing is promoted; turbofish optionality now leads its section. - `ReflectionGenericVariance` is now a unit enum. Thanks to everyone who has reviewed. Cheers, Seifeddine.

Tim Düsterhus

109 days ago
Hi Am 2026-05-10 21:02, schrieb Seifeddine Gmati:
> - RFC: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969
I haven't yet read the RFC itself in-depth. I generally agree with Rowan's points that PHP users expect the runtime to enforce types for them. With regard to the argument that “SA-checked” code could do without runtime type checks for performance, I would like to note some things that I have not seen mentioned (but I might have missed it in the depths of the discussion): Static analyzers can only prove the presence of errors, but not the absence of them. It is impossible to fully and accurately type check a PHP program without also executing it: The most obvious example would be `unserialize()` which can materialize arbitrary objects based on arbitrary inputs. `unserialize()` returns `mixed` for that reason. PHPStan only checks usage of `mixed` starting at level 9. Guess what level is being used by Symfony and Laravel respectively? If one would actually use the highest possible level of the static analysis tools they would need to “convince” the static analyzer that “yes, unserialize() is actually returning an object of the right type”. This is typically done with `assert($foo instanceof SomeClass);`, something that PHP will double-check for you at runtime. My understanding based on the discussion is that the RFC specifically excludes support for `instanceof SomeClass<SomeType>`, folks would need to fall back to `/** @var … */` comments or mark the offending line as ignored in some other way - which basically means that even *if* PHP supported generic syntax, they would need those doc comments. And the same is true for any “extra types” supported by static analyzers that are not supported by PHP itself, `non-empty-string`, `class-string`, integer ranges or similar. In an attempt to avoid as many false-negatives possible, static analysis tools are also rejecting perfectly valid - and reasonable - PHP code due to type mismatches. PHP allows to pass values of an “incorrect” type when they can be loss-lessly represented as the target type - and then guarantees that the stored value is of the correct type. This is particularly useful when going from int -> string. This perfectly valid PHP script is rejected by PHPStan with “Parameter #1 $x of function foo expects string, int given”. <?php function foo(string $x): void { echo $x; } foo(123); But at the same time this PHP script is accepted by PHPStan despite throwing “Uncaught TypeError: foo(): Argument #1 ($bar) must be of type string, int given” at runtime: <?php declare(strict_types = 1); function foo(string $bar): void { } foreach ($_GET as $key => $val) foo($key); Gina has probably more to say about `strict_types=1` actually being *less* safe than the default of using coercion (you'll probably find emails in the list archives). So depending on the configuration the existing - third party - static analysis tools are accepting programs written in a custom programming language that happens to share similarities with PHP such that it is understood by the Zend Engine, but differs from PHP in relevant aspects, being neither a subset, nor a superset of PHP. Best regards Tim Düsterhus

Seifeddine Gmati

109 days ago
Hi Tim,
> Static analyzers can only prove the presence of errors, but not the absence of them. [...] PHPStan only checks usage of `mixed` starting at level 9. Guess what level is being used by Symfony and Laravel respectively?
The level-9 framing is true today but not after this RFC ships. Generic type information currently lives in optional levels because it lives in optional syntax (docblocks). Once code lives in native syntax, SA tools must treat violations of it as hard errors at low levels. The move from optional-level checks to baseline checks is exactly what native syntax enables. Laravel, and Symfony use PHPStan/Psalm at lower levels today (higher in Psalm's case) because their codebases contain code that can't all be verified to the strictest standard without significant cleanup. Once generics are in PHP itself, the cleanup pressure shifts: a misuse becomes a language-level violation that any SA tool will flag at any level, not a level-9-only concern. What changes after this RFC is the *category* of generic violations: they become language-level errors rather than optional-level checks. SA tools will report them at the user's current level, not push the user to a higher level. A Laravel codebase running PHPStan at level 5 today would achieve full generic-arity, bound, and parametric LSP enforcement at level 5 after migrating to native generics, without changing the level. That's the point: native syntax means the language did the work, and tools surface violations at whatever strictness the user already has configured.
> This is typically done with `assert($foo instanceof SomeClass);`, something that PHP will double-check for you at runtime. My understanding based on the discussion is that the RFC specifically excludes support for `instanceof SomeClass<SomeType>` [...]
This is independent of the RFC. `unserialize()` returning a value of the right type has always required user-side assertion in PHP, and will continue to. The RFC doesn't ship `instanceof Box<int>` because the type argument isn't carried at runtime under bound erasure, same constraint Java, Kotlin, Scala, and Hack live with, and the same workaround applies: assert against the bound class (`$foo instanceof Box`), then use a docblock or static analysis for the type-argument narrowing. See: https://kotlinlang.org/docs/generics.html#generics-type-checks-and-casts This is one of the cases the RFC explicitly defers to a future reified-generics RFC. Saying "this RFC doesn't solve unserialize-narrowing" is correct but it's the same argument as saying "this RFC doesn't solve every type-system gap PHP has ever had." While true, that isn't an argument against shipping the parts it does solve.
> [...] integer ranges or similar.
PHP's type system has grown one feature per RFC for a decade: scalar types, union types, intersection types, DNF, true/false/null types. None of those shipped the entire wishlist either. `class-string<T>`, integer ranges, non-empty-string, negated types, and literal types can each be their own future RFC.
> But at the same time this PHP script is accepted by PHPStan despite throwing "Uncaught TypeError: foo(): Argument #1 ($bar) must be of type string, int given" at runtime [...]
That's a bug worth reporting to PHPStan. Mago catches it correctly https://mago.carthage.software/1.27.1/en/playground/#019e28c0-28e7-a525-cb82-710da883858c, disagreement between SA tools on specific cases is a real ecosystem issue (this RFC's "Why people use generics" mentions this to an extent), and tools improve over time. A false positive, or a false negative in one tool doesn't mean that "SA-checked code is a different language." (You will find a ton of false positives in Mago and Psalm, too.)
> So depending on the configuration the existing - third party - static analysis tools are accepting programs written in a custom programming language [...] being neither a subset, nor a superset of PHP.
This framing proves more than you'd want it to. If SA-checked PHP is "a different language" because tools occasionally disagree with the runtime, then by the same logic Symfony, Doctrine, Laravel, PHPUnit, PSL, and most of the major PHP ecosystem are "not PHP". Every one of those projects relies on SA to an extent to verify code the engine doesn't check, including the generic type relationships that motivate this RFC. That's a substantial portion of production PHP. Drawing the boundary that way means one of two things: 1. The language should grow to verify everything those tools verify (which is a much more aggressive runtime-enforcement position than even Rowan or Larry are taking) 2. The "different language" framing is too strict. The actual situation is that PHP has a runtime-checked layer (what the engine validates) and an SA-checked layer (what tools verify). The two layers complement each other. Native generics formalize a part of the SA-checked layer that the engine can partially absorb. The declaration-side, link-time, and turbofish-arity validation mentioned in the RFC's enforcement section is actual engine work, not just syntax. What's left in the SA-checked layer is the parametric-flow analysis, which the language lacks the capability to perform. Cheers, Seifeddine.

Zebulan

109 days ago
On Thursday, May 14th, 2026 at 6:33 PM, Seifeddine Gmati <azjezz@carthage.software> wrote:
> > But at the same time this PHP script is accepted by PHPStan despite throwing "Uncaught TypeError: foo(): Argument #1 ($bar) must be of type string, int given" at runtime [...] > > That's a bug worth reporting to PHPStan. Mago catches it correctly > https://mago.carthage.software/1.27.1/en/playground/#019e28c0-28e7-a525-cb82-710da883858c, > disagreement between SA tools on specific cases is a real ecosystem > issue (this RFC's "Why people use generics" mentions this to an > extent), and tools improve over time. A false positive, or a false > negative in one tool doesn't mean that "SA-checked code is a different > language." (You will find a ton of false positives in Mago and Psalm, > too.)
Notably, PHPStan is already working on solutions to this kind of issue: https://phpstan.org/blog/why-array-string-keys-are-not-type-safe

Rowan Tommins [IMSoP]

109 days ago
On 15 May 2026 00:32:05 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:
>Generic type information currently lives in optional levels because it >lives in optional syntax (docblocks).
This is, quite frankly, nonsense. If you write a function with no native type information, but an "@return int" docblock, PHPStan will report an error for a missing return statement at Level 0, and for an incorrect return statement on Level 3. There's no relationship between the syntax needed and the types of analysis performed.
> That's the point: native syntax means the language >did the work, and tools surface violations at whatever strictness the >user already has configured.
As Daniil pointed out, SA tools analyse code with offline parsers, not by loading and reflecting it; so the native enforcement of arity etc will still need to be reimplemented in each tool. The RFC will act as a standardisation of what tools *should* enforce around those things; but that could equally be done by agreeing a set of conformance tests based on the existing docblock syntax. In fact, those conformance tests would be needed whatever the syntax, if the goal is to eliminate different handling in different tools.
>The actual situation is that PHP has a runtime-checked layer (what the >engine validates) and an SA-checked layer (what tools verify). The two >layers complement each other. Native generics formalize a part of the >SA-checked layer that the engine can partially absorb.
I can agree with this framing. I think where we differ is that you see unifying those layers in one syntax as a good thing, but I see it as a bad thing: I think it is useful to be able to look at the code, and understand which parts are definitely going to be enforced by the runtime-checked layer. I think the ideal is to somehow create a standardised syntax for the SA-checked layer, but still keep it separate from the syntax for the runtime-checked layer. If neither docblocks nor attributes are a good basis for that, maybe there's some other primitive we can add so that users can mark both the "runtime type" and the "SA type". Straw man example: class Foo ~~Foo<T> extends Bar ~~Bar<int,T> { public function foo(string ~~non-empty-string $in): array ~~list<T> { ... Maybe PHP would process the syntax, but not the semantics, of the extra type information; SA tools would then be free to invent new pseudotypes within that framework, without needing to wait for a full PHP release cycle every time. Rowan Tommins [IMSoP]

Nicolas Grekas

109 days ago
Hi all, Thanks Seifeddine for putting this together. This is an impressive piece of work, and the general approach is sound to me. Looking forward to where this discussion lands; I hope we'll reach enough consensus to merge. Le ven. 15 mai 2026 à 13:13, Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk> a écrit :
> On 15 May 2026 00:32:05 BST, Seifeddine Gmati <azjezz@carthage.software> > wrote: > > >Generic type information currently lives in optional levels because it > >lives in optional syntax (docblocks). > > > This is, quite frankly, nonsense. > > If you write a function with no native type information, but an "@return > int" docblock, PHPStan will report an error for a missing return statement > at Level 0, and for an incorrect return statement on Level 3. > > There's no relationship between the syntax needed and the types of > analysis performed. > > > > That's the point: native syntax means the language > >did the work, and tools surface violations at whatever strictness the > >user already has configured. > > > As Daniil pointed out, SA tools analyse code with offline parsers, not by > loading and reflecting it; so the native enforcement of arity etc will > still need to be reimplemented in each tool. > > The RFC will act as a standardisation of what tools *should* enforce > around those things; but that could equally be done by agreeing a set of > conformance tests based on the existing docblock syntax. In fact, those > conformance tests would be needed whatever the syntax, if the goal is to > eliminate different handling in different tools. > > > >The actual situation is that PHP has a runtime-checked layer (what the > >engine validates) and an SA-checked layer (what tools verify). The two > >layers complement each other. Native generics formalize a part of the > >SA-checked layer that the engine can partially absorb. > > > I can agree with this framing. I think where we differ is that you see > unifying those layers in one syntax as a good thing, but I see it as a bad > thing: I think it is useful to be able to look at the code, and understand > which parts are definitely going to be enforced by the runtime-checked > layer. >
I'd like to put a syntax proposal on the table that may address Rowan's concern, and that also addresses something I'm worried about independently: the migration path for existing libraries. How does a library that uses @template docblocks today migrate to native syntax without forcing a BC break on its consumers? Multiplied across the libraries out there, this is a major point of tension for the ecosystem we need to anticipate. Existing @template annotations were adoptable gradually *because* they're invisible to the engine. Native <T> syntax, as the RFC proposes it, doesn't have that property: a library cannot adopt it without bumping its minimum PHP version and pulling all its consumers with it. We've solved this exact problem once before for attributes. The #[...] syntax was deliberately designed so older PHP versions would parse it as a # line comment, which is what made the adoption across the ecosystem so smooth. The same trick is available for generics if we pick the right delimiter. Concretely, write #<...> everywhere <...> appears in the current RFC: declarations, inheritance clauses, type uses in signatures, call-site arguments. One syntax, used uniformly. Three benefits, beyond the migration story: 1. FC for free. A library can adopt native generics in source today and continue running on older PHP versions, because the engine just sees comments. No need to coordinate with the min-PHP bump. 2. The turbofish goes away. No need to disambiguate < from less-than comparison. With #<...>, the token is unique and unambiguous everywhere: at declaration, use, and call site. We get to drop a whole grammar mechanism rather than introduce one. 3. Rowan's concern is addressed typographically. Anything inside #<...> is the erased, SA-enforced layer; everything outside follows the engine's normal runtime-checked rules. For codebases that want to adopt native generics while still supporting earlier PHP versions, #<...> would need to sit at the end of a line so older parsers consume it as a # line comment. Code targeting only generics-aware PHP can write it inline. The line-break constraint is a transitional code-style cost, not a permanent property of the syntax, and it's bounded by however long libraries support pre-generics versions. WDYT? I expect Seifeddine has good reasons to prefer the current syntax. I'd like to put this on the table because the FC story it unlocks, combined with the turbofish simplification, might be worth the trade-off and might help gather a broader consensus. Cheers, Nicolas

Barel

109 days ago
> > > I'd like to put a syntax proposal on the table that may address Rowan's > concern, and that also addresses something I'm worried about independently: > the migration path for existing libraries. > > How does a library that uses @template docblocks today migrate to native > syntax without forcing a BC break on its consumers? Multiplied across the > libraries out there, this is a major point of tension for the ecosystem we > need to anticipate. > Existing @template annotations were adoptable gradually *because* they're > invisible to the engine. > Native <T> syntax, as the RFC proposes it, doesn't have that property: a > library cannot adopt it without bumping its minimum PHP version and pulling > all its consumers with it. > > We've solved this exact problem once before for attributes. The #[...] > syntax was deliberately designed so older PHP versions would parse it as a > # line comment, which is what made the adoption across the ecosystem so > smooth. > The same trick is available for generics if we pick the right delimiter. > Concretely, write #<...> everywhere <...> appears in the current RFC: > declarations, inheritance clauses, type uses in signatures, call-site > arguments. One syntax, used uniformly. > > Three benefits, beyond the migration story: > > 1. FC for free. A library can adopt native generics in source today and > continue running on older PHP versions, because the engine just sees > comments. No need to coordinate with the min-PHP bump. > 2. The turbofish goes away. No need to disambiguate < from less-than > comparison. With #<...>, the token is unique and unambiguous everywhere: at > declaration, use, and call site. We get to drop a whole grammar mechanism > rather than introduce one. > 3. Rowan's concern is addressed typographically. Anything inside #<...> is > the erased, SA-enforced layer; everything outside follows the engine's > normal runtime-checked rules. > > For codebases that want to adopt native generics while still supporting > earlier PHP versions, #<...> would need to sit at the end of a line so > older parsers consume it as a # line comment. Code targeting only > generics-aware PHP can write it inline. The line-break constraint is a > transitional code-style cost, not a permanent property of the syntax, and > it's bounded by however long libraries support pre-generics versions. > > WDYT? I expect Seifeddine has good reasons to prefer the current syntax. > I'd like to put this on the table because the FC story it unlocks, combined > with the turbofish simplification, might be worth the trade-off and might > help gather a broader consensus. > > Cheers, > Nicolas >
Nicolas, I think this is a brilliant idea. I was thinking that libraries would take ages to adopt this new syntax if they didn’t want to break compatibility with existing PHP versions, and your proposal solves this issue in a great way. The price to pay is slightly uglier syntax, but we already went through that with attributes. I think that initially we all thought the attribute syntax was quite terrible, but nowadays no one bats an eye at it. Cheers, Carlos

Zebulan

109 days ago
On Friday, May 15th, 2026 at 7:13 AM, Nicolas Grekas <nicolas.grekas+php@gmail.com> wrote:
> I'd like to put a syntax proposal on the table that may address Rowan's concern, and that also addresses something I'm worried about independently: the migration path for existing libraries. > How does a library that uses @template docblocks today migrate to native syntax without forcing a BC break on its consumers? Multiplied across the libraries out there, this is a major point of tension for the ecosystem we need to anticipate. > Existing @template annotations were adoptable gradually *because* they're invisible to the engine. > Native <T> syntax, as the RFC proposes it, doesn't have that property: a library cannot adopt it without bumping its minimum PHP version and pulling all its consumers with it. > We've solved this exact problem once before for attributes. The #[...] syntax was deliberately designed so older PHP versions would parse it as a # line comment, which is what made the adoption across the ecosystem so smooth. > The same trick is available for generics if we pick the right delimiter. Concretely, write #<...> everywhere <...> appears in the current RFC: declarations, inheritance clauses, type uses in signatures, call-site arguments. One syntax, used uniformly. > Three benefits, beyond the migration story: > > 1. FC for free. A library can adopt native generics in source today and continue running on older PHP versions, because the engine just sees comments. No need to coordinate with the min-PHP bump. > 2. The turbofish goes away. No need to disambiguate < from less-than comparison. With #<...>, the token is unique and unambiguous everywhere: at declaration, use, and call site. We get to drop a whole grammar mechanism rather than introduce one. > 3. Rowan's concern is addressed typographically. Anything inside #<...> is the erased, SA-enforced layer; everything outside follows the engine's normal runtime-checked rules. > > For codebases that want to adopt native generics while still supporting earlier PHP versions, #<...> would need to sit at the end of a line so older parsers consume it as a # line comment. Code targeting only generics-aware PHP can write it inline. The line-break constraint is a transitional code-style cost, not a permanent property of the syntax, and it's bounded by however long libraries support pre-generics versions. > WDYT? I expect Seifeddine has good reasons to prefer the current syntax. I'd like to put this on the table because the FC story it unlocks, combined with the turbofish simplification, might be worth the trade-off and might help gather a broader consensus. > Cheers, > Nicolas >
How would generic types be expressed in parameters and return types? ```php // Current RFC: function DoStuff<T>(myParam: T, otherParam: int): T { // ... } ``` Existing PHP versions will have no clue what `T` is. Only one of the uses of `T` here is inside `<...>`. Wrapping the others in `#<...>` would be syntactically incoherent (and rather ugly, I think). But the only alternative that comes to mind is: ```php function DoStuff#<T>(myParam#: T, otherParam: int)#: T {} // which would have to look like this in projects continuing to support prior PHP versions: function DoStuff#<T> ( myParam#: T , otherParam: int // bizarre comma placement )#: T { // ... } ``` Syntax parsing would, I suspect, be rather more complicated, unless you required the type to be placed in parentheses, which would only make the syntax even less appealing: ```php function DoStuff#<T>(myParam#:(T), otherParam: int)#:(T) {} // and in projects continuing to support prior PHP versions: function DoStuff#<T> ( myParam#:(T) , otherParam: int )#:(T) { // ... } ``` If not for those (major, in my view) syntactical issues, I might've been on board with the idea. (Just to be clear, I don't have voting privileges.) Conceptually, it feels proper to use "#" again for structured metadata that has *some* engine-enforced functionality, but which is primarily targeted at (static analysis) developer tools. But once one realizes that generics will be used in places outside of `<...>`, I think the viability of the syntax crumbles, and I'd rather just have the original proposal.

Zebulan

109 days ago
On Friday, May 15th, 2026 at 10:56 AM, Zebulan <zebulanstanphill@protonmail.com> wrote:
> How would generic types be expressed in parameters and return types? > > ```php > // Current RFC: > function DoStuff<T>(myParam: T, otherParam: int): T { > // ... > } > ``` > > Existing PHP versions will have no clue what `T` is. Only one of the uses of `T` here is inside `<...>`. Wrapping the others in `#<...>` would be syntactically incoherent (and rather ugly, I think). But the only alternative that comes to mind is: > > ```php > function DoStuff#<T>(myParam#: T, otherParam: int)#: T {} > > // which would have to look like this in projects continuing to support prior PHP versions: > function DoStuff#<T> > ( > myParam#: T > , otherParam: int // bizarre comma placement > )#: T > { > // ... > } > ``` > > Syntax parsing would, I suspect, be rather more complicated, unless you required the type to be placed in parentheses, which would only make the syntax even less appealing: > > > ```php > function DoStuff#<T>(myParam#:(T), otherParam: int)#:(T) {} > > // and in projects continuing to support prior PHP versions: > function DoStuff#<T> > ( > myParam#:(T) > , otherParam: int > )#:(T) > { > // ... > } > ```
Whoops, my mind was in TypeScript-mode and I used suffix-style param types in my examples. Still, as Larry's comment points out, the `#`-style syntax is still pretty ugly (in fact, it might actually be worse) with actual PHP-style syntax.

Larry Garfield

109 days ago
On Fri, May 15, 2026, at 7:09 AM, Nicolas Grekas wrote:
> Hi all, > > Thanks Seifeddine for putting this together. This is an impressive > piece of work, and the general approach is sound to me. Looking forward > to where this discussion lands; I hope we'll reach enough consensus to > merge. > > Le ven. 15 mai 2026 à 13:13, Rowan Tommins [IMSoP] > <imsop.php@rwec.co.uk> a écrit : >> On 15 May 2026 00:32:05 BST, Seifeddine Gmati <azjezz@carthage.software> wrote: >> >> >Generic type information currently lives in optional levels because it >> >lives in optional syntax (docblocks). >> >> >> This is, quite frankly, nonsense. >> >> If you write a function with no native type information, but an "@return int" docblock, PHPStan will report an error for a missing return statement at Level 0, and for an incorrect return statement on Level 3. >> >> There's no relationship between the syntax needed and the types of analysis performed. >> >> >> > That's the point: native syntax means the language >> >did the work, and tools surface violations at whatever strictness the >> >user already has configured. >> >> >> As Daniil pointed out, SA tools analyse code with offline parsers, not by loading and reflecting it; so the native enforcement of arity etc will still need to be reimplemented in each tool. >> >> The RFC will act as a standardisation of what tools *should* enforce around those things; but that could equally be done by agreeing a set of conformance tests based on the existing docblock syntax. In fact, those conformance tests would be needed whatever the syntax, if the goal is to eliminate different handling in different tools. >> >> >> >The actual situation is that PHP has a runtime-checked layer (what the >> >engine validates) and an SA-checked layer (what tools verify). The two >> >layers complement each other. Native generics formalize a part of the >> >SA-checked layer that the engine can partially absorb. >> >> >> I can agree with this framing. I think where we differ is that you see unifying those layers in one syntax as a good thing, but I see it as a bad thing: I think it is useful to be able to look at the code, and understand which parts are definitely going to be enforced by the runtime-checked layer. > > I'd like to put a syntax proposal on the table that may address Rowan's > concern, and that also addresses something I'm worried about > independently: the migration path for existing libraries. > > How does a library that uses @template docblocks today migrate to > native syntax without forcing a BC break on its consumers? Multiplied > across the libraries out there, this is a major point of tension for > the ecosystem we need to anticipate. > Existing @template annotations were adoptable gradually *because* > they're invisible to the engine. > Native <T> syntax, as the RFC proposes it, doesn't have that property: > a library cannot adopt it without bumping its minimum PHP version and > pulling all its consumers with it. > > We've solved this exact problem once before for attributes. The #[...] > syntax was deliberately designed so older PHP versions would parse it > as a # line comment, which is what made the adoption across the > ecosystem so smooth.
Though recall that was an after-passage change; the original attribute syntax was << >>, which everyone apparently hated, so we changed it twice in rapid succession. (I am very very glad we did switch to the Rust-inspired syntax, but just making sure the history is clear.)
> The same trick is available for generics if we pick the right > delimiter. Concretely, write #<...> everywhere <...> appears in the > current RFC: declarations, inheritance clauses, type uses in > signatures, call-site arguments. One syntax, used uniformly. > > Three benefits, beyond the migration story: > > 1. FC for free. A library can adopt native generics in source today and > continue running on older PHP versions, because the engine just sees > comments. No need to coordinate with the min-PHP bump. > 2. The turbofish goes away. No need to disambiguate < from less-than > comparison. With #<...>, the token is unique and unambiguous > everywhere: at declaration, use, and call site. We get to drop a whole > grammar mechanism rather than introduce one. > 3. Rowan's concern is addressed typographically. Anything inside #<...> > is the erased, SA-enforced layer; everything outside follows the > engine's normal runtime-checked rules. > > For codebases that want to adopt native generics while still supporting > earlier PHP versions, #<...> would need to sit at the end of a line so > older parsers consume it as a # line comment. Code targeting only > generics-aware PHP can write it inline. The line-break constraint is a > transitional code-style cost, not a permanent property of the syntax, > and it's bounded by however long libraries support pre-generics > versions. > > WDYT? I expect Seifeddine has good reasons to prefer the current > syntax. I'd like to put this on the table because the FC story it > unlocks, combined with the turbofish simplification, might be worth the > trade-off and might help gather a broader consensus. > > Cheers, > Nicolas
I'm not a huge fan of this approach. With attributes, most use cases already had a natural line-break built in. The only one that didn't was parameter attributes, and that was easy enough to work around by veritcalizing the parameter list, which is already common practice when it gets long. In this case, what you're proposing is I'd need to write something like this: class Foo #<Bar> implements Baz #<Beep> { public do( #Bar $bar) : List #<string> ) } That's just disgusting. :-) I'm never going to do that. I'll just write it properly, but now I have comment tags floating around my code forever that aren't actually comments. Please, no. Really, Attributes' FC-friendliness was a one-off. Pretty much any other new syntax we've added has always been a "upgrade or get a parse error, deal", like any other language. I realize Symfony and its BC policy puts it in a position where a hard-cut to the new syntax would be harder than for most projects, but the cost is just way too high. --Larry Garfield

Nicolas Grekas

109 days ago
Le ven. 15 mai 2026 à 18:03, Larry Garfield <larry@garfieldtech.com> a écrit :
> > On Fri, May 15, 2026, at 7:09 AM, Nicolas Grekas wrote: > > Hi all, > > > > Thanks Seifeddine for putting this together. This is an impressive > > piece of work, and the general approach is sound to me. Looking forward > > to where this discussion lands; I hope we'll reach enough consensus to > > merge. > > > > Le ven. 15 mai 2026 à 13:13, Rowan Tommins [IMSoP] > > <imsop.php@rwec.co.uk> a écrit : > >> On 15 May 2026 00:32:05 BST, Seifeddine Gmati <azjezz@carthage.software> > wrote: > >> > >> >Generic type information currently lives in optional levels because it > >> >lives in optional syntax (docblocks). > >> > >> > >> This is, quite frankly, nonsense. > >> > >> If you write a function with no native type information, but an > "@return int" docblock, PHPStan will report an error for a missing return > statement at Level 0, and for an incorrect return statement on Level 3. > >> > >> There's no relationship between the syntax needed and the types of > analysis performed. > >> > >> > >> > That's the point: native syntax means the language > >> >did the work, and tools surface violations at whatever strictness the > >> >user already has configured. > >> > >> > >> As Daniil pointed out, SA tools analyse code with offline parsers, not > by loading and reflecting it; so the native enforcement of arity etc will > still need to be reimplemented in each tool. > >> > >> The RFC will act as a standardisation of what tools *should* enforce > around those things; but that could equally be done by agreeing a set of > conformance tests based on the existing docblock syntax. In fact, those > conformance tests would be needed whatever the syntax, if the goal is to > eliminate different handling in different tools. > >> > >> > >> >The actual situation is that PHP has a runtime-checked layer (what the > >> >engine validates) and an SA-checked layer (what tools verify). The two > >> >layers complement each other. Native generics formalize a part of the > >> >SA-checked layer that the engine can partially absorb. > >> > >> > >> I can agree with this framing. I think where we differ is that you see > unifying those layers in one syntax as a good thing, but I see it as a bad > thing: I think it is useful to be able to look at the code, and understand > which parts are definitely going to be enforced by the runtime-checked > layer. > > > > I'd like to put a syntax proposal on the table that may address Rowan's > > concern, and that also addresses something I'm worried about > > independently: the migration path for existing libraries. > > > > How does a library that uses @template docblocks today migrate to > > native syntax without forcing a BC break on its consumers? Multiplied > > across the libraries out there, this is a major point of tension for > > the ecosystem we need to anticipate. > > Existing @template annotations were adoptable gradually *because* > > they're invisible to the engine. > > Native <T> syntax, as the RFC proposes it, doesn't have that property: > > a library cannot adopt it without bumping its minimum PHP version and > > pulling all its consumers with it. > > > > We've solved this exact problem once before for attributes. The #[...] > > syntax was deliberately designed so older PHP versions would parse it > > as a # line comment, which is what made the adoption across the > > ecosystem so smooth. > > Though recall that was an after-passage change; the original attribute > syntax was << >>, which everyone apparently hated, so we changed it twice > in rapid succession. (I am very very glad we did switch to the > Rust-inspired syntax, but just making sure the history is clear.) >
It was, and part of the reason why the new syntax was way better is that it was a comment to the older engine version. Retrospectively, we can all testify how much this boosted if not just permitted broad adoption.
> > > The same trick is available for generics if we pick the right > > delimiter. Concretely, write #<...> everywhere <...> appears in the > > current RFC: declarations, inheritance clauses, type uses in > > signatures, call-site arguments. One syntax, used uniformly. > > > > Three benefits, beyond the migration story: > > > > 1. FC for free. A library can adopt native generics in source today and > > continue running on older PHP versions, because the engine just sees > > comments. No need to coordinate with the min-PHP bump. > > 2. The turbofish goes away. No need to disambiguate < from less-than > > comparison. With #<...>, the token is unique and unambiguous > > everywhere: at declaration, use, and call site. We get to drop a whole > > grammar mechanism rather than introduce one. > > 3. Rowan's concern is addressed typographically. Anything inside #<...> > > is the erased, SA-enforced layer; everything outside follows the > > engine's normal runtime-checked rules. > > > > For codebases that want to adopt native generics while still supporting > > earlier PHP versions, #<...> would need to sit at the end of a line so > > older parsers consume it as a # line comment. Code targeting only > > generics-aware PHP can write it inline. The line-break constraint is a > > transitional code-style cost, not a permanent property of the syntax, > > and it's bounded by however long libraries support pre-generics > > versions. > > > > WDYT? I expect Seifeddine has good reasons to prefer the current > > syntax. I'd like to put this on the table because the FC story it > > unlocks, combined with the turbofish simplification, might be worth the > > trade-off and might help gather a broader consensus. > > > > Cheers, > > Nicolas > > I'm not a huge fan of this approach. With attributes, most use cases > already had a natural line-break built in. The only one that didn't was > parameter attributes, and that was easy enough to work around by > veritcalizing the parameter list, which is already common practice when it > gets long. > > In this case, what you're proposing is I'd need to write something like > this: > > class Foo > #<Bar> > implements Baz > #<Beep> > { > public do( > #Bar > $bar) : List > #<string> > ) > } >
not so much - and only transitory for the libs that care about this - vs no similar path in the proposed RFC as is: class Foo#<T> implements Baz#<U> { public do( #<T> // <- this could be a way to address your concern Zebulan - for Larry, that's already what we do for attributes on args DateTimeInterface $bar, ) : List#<string> { // [...] } }
> > That's just disgusting. :-) I'm never going to do that. I'll just write > it properly, but now I have comment tags floating around my code forever > that aren't actually comments. Please, no. > > Really, Attributes' FC-friendliness was a one-off. Pretty much any other > new syntax we've added has always been a "upgrade or get a parse error, > deal", like any other language.
I realize Symfony and its BC policy puts it in a position where a hard-cut
> to the new syntax would be harder than for most projects, but the cost is > just way too high. >
There's nothing specific about Symfony here - it's all just about making adoption smooth if not just possible - broadly. See perl6 or even python3 legacy on the topic. Nicolas

Lynn

108 days ago
On Fri, May 15, 2026 at 6:44 PM Nicolas Grekas <nicolas.grekas+php@gmail.com> wrote:
> > not so much - and only transitory for the libs that care about this - vs > no similar path in the proposed RFC as is: > > class Foo#<T> > implements Baz#<U> > { > public do( > #<T> // <- this could be a way to address your concern Zebulan - > for Larry, that's already what we do for attributes on args > DateTimeInterface > $bar, > ) : List#<string> > { > // [...] > } > } >
Would it be an option to add both variations, where #<...> functions purely as forward compatibility?

Bob Weinand

109 days ago
Hey Nicolas, Am 15.05.2026 um 14:09 schrieb Nicolas Grekas <nicolas.grekas+php@gmail.com>: I'd like to put a syntax proposal on the table that may address Rowan's concern, and that also addresses something I'm worried about independently: the migration path for existing libraries. How does a library that uses @template docblocks today migrate to native syntax without forcing a BC break on its consumers? Multiplied across the libraries out there, this is a major point of tension for the ecosystem we need to anticipate. Existing @template annotations were adoptable gradually *because* they're invisible to the engine. Native <T> syntax, as the RFC proposes it, doesn't have that property: a library cannot adopt it without bumping its minimum PHP version and pulling all its consumers with it. We've solved this exact problem once before for attributes. The #[...] syntax was deliberately designed so older PHP versions would parse it as a # line comment, which is what made the adoption across the ecosystem so smooth. The same trick is available for generics if we pick the right delimiter. Concretely, write #<...> everywhere <...> appears in the current RFC: declarations, inheritance clauses, type uses in signatures, call-site arguments. One syntax, used uniformly. Three benefits, beyond the migration story: 1. FC for free. A library can adopt native generics in source today and continue running on older PHP versions, because the engine just sees comments. No need to coordinate with the min-PHP bump. 2. The turbofish goes away. No need to disambiguate < from less-than comparison. With #<...>, the token is unique and unambiguous everywhere: at declaration, use, and call site. We get to drop a whole grammar mechanism rather than introduce one. 3. Rowan's concern is addressed typographically. Anything inside #<...> is the erased, SA-enforced layer; everything outside follows the engine's normal runtime-checked rules. For codebases that want to adopt native generics while still supporting earlier PHP versions, #<...> would need to sit at the end of a line so older parsers consume it as a # line comment. Code targeting only generics-aware PHP can write it inline. The line-break constraint is a transitional code-style cost, not a permanent property of the syntax, and it's bounded by however long libraries support pre-generics versions. WDYT? I expect Seifeddine has good reasons to prefer the current syntax. I'd like to put this on the table because the FC story it unlocks, combined with the turbofish simplification, might be worth the trade-off and might help gather a broader consensus. Cheers, Nicolas I'd like to ask you: do we need to migrate this quickly? For attributes the #[] syntax was chosen, because it was actually on the table in the first place - we needed a delimiter - and by chance it's something generally on its dedicated line of code - the primary target being classes and methods. Types? They're neither generally suffixed to a line (except for classes with many extends/implements and for classes without any as well as return types). Everything else is in the middle of the line. It doesn't really fit here. And also it has a bit of ugliness. Would you really want to use #<...> long-term? I wouldn't compromise long-term syntax for short-term benefits. Also, again, do we need people to switch over to the language provided generics right now? As far as I see, most libraries and frameworks support a 2~3 years old PHP version on their master version (I.e. what becomes their next major). People can *still* use @template for now. It's not a fundamental ability which wouldn't be there without this feature. Unlike attributes, which were more or less not doable without manual hand-rolled docblock parsing (as opposed to a handful static analysis tools which handle docblock parsing). Another benefit of it would probably be, if we were to introduce reified generics over the next few versions of PHP, the blast radius of previously-unverified types now becoming checked is much smaller than when every framework already had first-class support for it. So, having a slower adoption of language-generics could actually be a feature rather than an annoyance. Bob

Nicolas Grekas

108 days ago
Le ven. 15 mai 2026 à 19:31, Bob Weinand <bobwei9@hotmail.com> a écrit :
> Hey Nicolas, > > Am 15.05.2026 um 14:09 schrieb Nicolas Grekas < > nicolas.grekas+php@gmail.com>: > > I'd like to put a syntax proposal on the table that may address Rowan's > concern, and that also addresses something I'm worried about independently: > the migration path for existing libraries. > > How does a library that uses @template docblocks today migrate to native > syntax without forcing a BC break on its consumers? Multiplied across the > libraries out there, this is a major point of tension for the ecosystem we > need to anticipate. > Existing @template annotations were adoptable gradually *because* they're > invisible to the engine. > Native <T> syntax, as the RFC proposes it, doesn't have that property: a > library cannot adopt it without bumping its minimum PHP version and pulling > all its consumers with it. > > We've solved this exact problem once before for attributes. The #[...] > syntax was deliberately designed so older PHP versions would parse it as a > # line comment, which is what made the adoption across the ecosystem so > smooth. > The same trick is available for generics if we pick the right delimiter. > Concretely, write #<...> everywhere <...> appears in the current RFC: > declarations, inheritance clauses, type uses in signatures, call-site > arguments. One syntax, used uniformly. > > Three benefits, beyond the migration story: > > 1. FC for free. A library can adopt native generics in source today and > continue running on older PHP versions, because the engine just sees > comments. No need to coordinate with the min-PHP bump. > 2. The turbofish goes away. No need to disambiguate < from less-than > comparison. With #<...>, the token is unique and unambiguous everywhere: at > declaration, use, and call site. We get to drop a whole grammar mechanism > rather than introduce one. > 3. Rowan's concern is addressed typographically. Anything inside #<...> is > the erased, SA-enforced layer; everything outside follows the engine's > normal runtime-checked rules. > > For codebases that want to adopt native generics while still supporting > earlier PHP versions, #<...> would need to sit at the end of a line so > older parsers consume it as a # line comment. Code targeting only > generics-aware PHP can write it inline. The line-break constraint is a > transitional code-style cost, not a permanent property of the syntax, and > it's bounded by however long libraries support pre-generics versions. > > WDYT? I expect Seifeddine has good reasons to prefer the current syntax. > I'd like to put this on the table because the FC story it unlocks, combined > with the turbofish simplification, might be worth the trade-off and might > help gather a broader consensus. > > Cheers, > Nicolas > > > I'd like to ask you: do we need to migrate this quickly? > > For attributes the #[] syntax was chosen, because it was actually on the > table in the first place - we needed a delimiter - and by chance it's > something generally on its dedicated line of code - the primary target > being classes and methods. > > Types? They're neither generally suffixed to a line (except for classes > with many extends/implements and for classes without any as well as return > types). Everything else is in the middle of the line. > > It doesn't really fit here. And also it has a bit of ugliness. Would you > really want to use #<...> long-term? I wouldn't compromise long-term syntax > for short-term benefits. > > Also, again, do we need people to switch over to the language provided > generics right now? As far as I see, most libraries and frameworks support > a 2~3 years old PHP version on their master version (I.e. what becomes > their next major). > People can *still* use @template for now. It's not a fundamental ability > which wouldn't be there without this feature. > Unlike attributes, which were more or less not doable without manual > hand-rolled docblock parsing (as opposed to a handful static analysis tools > which handle docblock parsing). > > Another benefit of it would probably be, if we were to introduce reified > generics over the next few versions of PHP, the blast radius of > previously-unverified types now becoming checked is much smaller than when > every framework already had first-class support for it. > > So, having a slower adoption of language-generics could actually be a > feature rather than an annoyance. >
I'm personally in no hurry, but I know the community will. As soon as the syntax will be available, all sorts of libs will get PRs to add them. That's already happening with phpdocs all the time and that will only accelerate with native syntax. Of course, contributors will not care about BC concerns, and maintainers will be under pressure. Then what will happen in a few years is that SA tool maintainers will stop maintaining versions that used to support to-be-legacy phpdoc generics. That'll be a legit move. But that'll also mean that all projects that did not move to native types because of BC concerns will be left in the dust: no maintained SA tools and no forward path except BC breaks. This is mechanical thinking, I'm not Nostradamus :) It will happen with friction, and now is the time to think ahead. It's not a matter of when, it's a matter of how. That being said, there's another plan I didn't think of earlier: we (the community) could ship a preprocessor that'd just erase native generics before running code on versions that don't support them. userland-erased generics vs engine-erased generics. Same outcome. Is that the best plan ahead? Could be. I wouldn't totally dismiss the syntax proposal yet on the ground it might be better to bake this adoption concern into the RFC directly. At least I hope I made it clear enough why it's important to care. Cheers, Nicolas

Seifeddine Gmati

108 days ago
Hi Nicolas, The "SA tools drop legacy docblock support immediately" scenario isn't going to happen on the timeline you're worried about. What's more likely is a multi-year deprecation curve: tools will fully support docblock syntax for years after native generics land, then gradually stop adding *new* features to the docblock side (e.g., if a future RFC adds lower bounds or where constraints tools will support it natively but won't try to retrofit it into docblock syntax), and only after the ecosystem has clearly moved would full docblock support actually deprecate. I can speak for Mago: we won't drop docblock generic support for at least several years after native generics ship, because we need to keep supporting PHP versions earlier than the one this RFC lands in anyway. PHPStan and Psalm are in the same position: they each support a wide range of PHP versions. Dropping docblock generics would mean cutting off users on those older versions entirely, which I presume none of the maintainers want to do. The timeline math also helps. If this RFC ships in PHP 8.6, Symfony 9.1 in May 2028 would be the natural place to bump the minimum to 8.6, that's two years from now. And SA tools would most likely support docblock generics for even longer. the long-tail Symfony/Laravel/Doctrine projects would already be on PHP versions that support native syntax, and the migration would be available rather than required. The concern that contributor pull requests adding native generics before the project is ready will put pressure on maintainers is real, but it mirrors the shape of every previous PHP feature. Readonly properties, enums, named arguments, FCCs, etc. all of these created the same dynamic, which frameworks worked out using version constraints (`composer.json` `"php": ">=8.0"`), conditional code, and policy decisions about when to bump minimums. Native generics don't introduce a new shape of problem; they fit the existing one. On the preprocessor idea: I think the same concerns that ruled out userland generics preprocessors in the RFC's "Userland preprocessors" section apply here. Composer plugins can't reach the IDE, editor, debugger, profiler, test runner, or CI, the boundaries where developers actually work. A preprocessor that erases native generics before execution on older PHP would solve the runtime story but not the tooling story; SA tools, IDEs, and everything else still see the native syntax and need to handle it. So even with a userland preprocessor, projects that want native syntax in source still need their toolchain to support native generics. That said, if Symfony specifically needs a preprocessor that strips native generics for runtime execution on older PHP versions, I'd genuinely be willing to write one. Mago already has the parser, the formatter, and the AST-rewriting infrastructure to make this fairly straightforward; it's a weekend project, not a research one. Happy to do it if it actually unblocks adoption for Symfony. Just say the word. I don't think this warrants baking adoption concerns into the RFC text directly. The deprecation curve is going to be slow regardless of what the RFC says, and writing migration guidance into the RFC pre-commits to specific timelines that the ecosystem will resolve organically. If it would be useful, I can write a separate migration-guide blog post after the RFC settles, but I'd rather not freeze guidance into the spec. Cheers, Seif.

Nicolas Grekas

108 days ago
Le sam. 16 mai 2026 à 03:21, Seifeddine Gmati <azjezz@carthage.software> a écrit :
> Hi Nicolas, > > The "SA tools drop legacy docblock support immediately" scenario isn't > going to happen on the timeline you're worried about. What's more > likely is a multi-year deprecation curve: tools will fully support > docblock syntax for years after native generics land, then gradually > stop adding *new* features to the docblock side (e.g., if a future RFC > adds lower bounds or where constraints tools will support it natively > but won't try to retrofit it into docblock syntax), and only after the > ecosystem has clearly moved would full docblock support actually > deprecate. > > I can speak for Mago: we won't drop docblock generic support for at > least several years after native generics ship, because we need to > keep supporting PHP versions earlier than the one this RFC lands in > anyway. PHPStan and Psalm are in the same position: they each support > a wide range of PHP versions. Dropping docblock generics would mean > cutting off users on those older versions entirely, which I presume > none of the maintainers want to do. > > The timeline math also helps. If this RFC ships in PHP 8.6, Symfony > 9.1 in May 2028 would be the natural place to bump the minimum to 8.6, > that's two years from now. And SA tools would most likely support > docblock generics for even longer. the long-tail > Symfony/Laravel/Doctrine projects would already be on PHP versions > that support native syntax, and the migration would be available > rather than required. > > The concern that contributor pull requests adding native generics > before the project is ready will put pressure on maintainers is real, > but it mirrors the shape of every previous PHP feature. Readonly > properties, enums, named arguments, FCCs, etc. all of these created > the same dynamic, which frameworks worked out using version > constraints (`composer.json` `"php": ">=8.0"`), conditional code, and > policy decisions about when to bump minimums. Native generics don't > introduce a new shape of problem; they fit the existing one. > > On the preprocessor idea: I think the same concerns that ruled out > userland generics preprocessors in the RFC's "Userland preprocessors" > section apply here. Composer plugins can't reach the IDE, editor, > debugger, profiler, test runner, or CI, the boundaries where > developers actually work. A preprocessor that erases native generics > before execution on older PHP would solve the runtime story but not > the tooling story; SA tools, IDEs, and everything else still see the > native syntax and need to handle it. So even with a userland > preprocessor, projects that want native syntax in source still need > their toolchain to support native generics. > > That said, if Symfony specifically needs a preprocessor that strips > native generics for runtime execution on older PHP versions, I'd > genuinely be willing to write one. Mago already has the parser, the > formatter, and the AST-rewriting infrastructure to make this fairly > straightforward; it's a weekend project, not a research one. Happy to > do it if it actually unblocks adoption for Symfony. Just say the word. > > I don't think this warrants baking adoption concerns into the RFC text > directly. The deprecation curve is going to be slow regardless of what > the RFC says, and writing migration guidance into the RFC pre-commits > to specific timelines that the ecosystem will resolve organically. If > it would be useful, I can write a separate migration-guide blog post > after the RFC settles, but I'd rather not freeze guidance into the > spec
Thanks for your answer, it's good to read you understand the challenge that will exist once the RFC lands. I don't know if you're right about the timescale, but let's hope so. At least having this discussion today might help raise the need for LTS of docblock-based SA tools. Due noted for your help about a userland type-eraser when we'll be there :) About the BC break itself, I want to be clear that this is not the syntax that's going to cause a problem. Like you said, Symfony/whatever will bump to 8.6 one day (more likely in November 2027 in v9.0 for Symfony) and that day we'll be able to use the syntax. The BC concern is about adopting native generics without introducing BC breaks. Bob already shared the idea to raise deprecations instead of errors in https://externals.io/message/130816#130845 That might be the wise way forward. I sent you the message below already but missed adding internals in the loop: The RFC's call-site BC story is well designed: "adding generic parameters to a function does not require any caller to change." Existing pick($a, $b) calls keep working after pick gains <L, R>. That's the right design. My remaining concern is about inheritance. Adding a type parameter to a class breaks every subclass that does not supply an argument. The RFC points at defaults as the fix: class Foo<T = mixed> lets class Bar extends Foo {} keep compiling. This is the right tool for libraries that adopt generics with defaults from day one. But libraries with strict bounds may have no sensible default: class Foo<T : SomeInterface> cannot default to mixed, and there is no wildcard for "any class implementing SomeInterface". These libraries have no migration path that does not break every subclass at compile time. The ecosystem will pay this cost one library at a time and this will slow down adoption by a lot. I'd like to suggest adding a deprecation window for missing inheritance arguments, independent of defaults. Concretely: for PHP version 8.x, class Bar extends Foo {} against a class Foo<T> without a default emits E_DEPRECATED and behaves as if T was substituted by its bound, rather than failing at compile time. Then PHP v9.0 makes it a hard error. This turns "hard BC breaks ahead" into "guided migration". This is how PHP already did for most other type-system changes: return types added step by step, nullable parameters, implicit nullability deprecation, dynamic properties. The pattern is familiar and the ecosystem knows how to deal with it. It's the same shape as the turbofish optionality already in the RFC: adding <T> to a function would not force callers to change. The deprecation window extends this to inheritance sites, which is where BC pressure also lives in the ecosystem. A library that adds <T> to a class today, on a generics-aware PHP version, would emit deprecations on user code that has not yet declared an argument, the way today's @template adopters have always managed this gap. Ppl that do want a hard failure as early as possible could still rely on SA tools of course. Adding generics to a class would become a gradual transition for every consumer, not a forced switch. I think this works with the existing call-site BC story rather than against it. It would help adoption a lot. Cheers, Nicolas

Seifeddine Gmati

108 days ago
Hi Nicolas, I'd like to push back on the inheritance-BC framing. The claim that bounded types have no sensible default or wildcard is not quite accurate; defaults can be any type expression that satisfies the bound, including the bound itself. For example, consider this docblock: ``` /** * @template Id of string|object * @template T of UserInterface */ interface UserProvider { /** * @param Id $id * @return T|null */ public function load(string|object $id): null|UserInterface; } ``` Under this RFC, the migration would be: ``` interface UserProvider< Id : string|object = string|object, T : UserInterface = UserInterface,
> {
public function load(Id $id): T|null; } ``` By using the bounds themselves as defaults, every existing implementer continues to work unchanged: ``` class DummyProvider implements UserProvider { public function load(string|object $x): ?UserInterface { return null; } } ``` This is the general pattern: for any bounded type parameter, the widest legal default is the bound itself. There is no shape of generic declaration where a library cannot supply a default that satisfies its own bound. The migration story for adding generics to existing classes is to declare parameters with the bound as the default. Every existing subclass continues to compile and run, while downstream code can specialize on its own schedule. Consequently, the deprecation-window proposal addresses a problem that doesn't exist if defaults are used correctly. If a library declares defaults equal to its bounds, missing arguments resolve to those defaults, maintaining existing behavior. A deprecation window would only be necessary if an author chooses not to provide sensible defaults. I don't see a need to bake a deprecation window into the RFC. The existing mechanism (using defaults equal to bounds), already covers the migration path for zero-BC adoption. Cheers, Seifeddine

Larry Garfield

109 days ago
On Fri, May 15, 2026, at 6:10 AM, Rowan Tommins [IMSoP] wrote:
> On 15 May 2026 00:32:05 BST, Seifeddine Gmati <azjezz@carthage.software> wrote: > >>Generic type information currently lives in optional levels because it >>lives in optional syntax (docblocks). > > > This is, quite frankly, nonsense. > > If you write a function with no native type information, but an > "@return int" docblock, PHPStan will report an error for a missing > return statement at Level 0, and for an incorrect return statement on > Level 3. > > There's no relationship between the syntax needed and the types of > analysis performed. > > >> That's the point: native syntax means the language >>did the work, and tools surface violations at whatever strictness the >>user already has configured. > > > As Daniil pointed out, SA tools analyse code with offline parsers, not > by loading and reflecting it; so the native enforcement of arity etc > will still need to be reimplemented in each tool. > > The RFC will act as a standardisation of what tools *should* enforce > around those things; but that could equally be done by agreeing a set > of conformance tests based on the existing docblock syntax. In fact, > those conformance tests would be needed whatever the syntax, if the > goal is to eliminate different handling in different tools. > > >>The actual situation is that PHP has a runtime-checked layer (what the >>engine validates) and an SA-checked layer (what tools verify). The two >>layers complement each other. Native generics formalize a part of the >>SA-checked layer that the engine can partially absorb. > > > I can agree with this framing. I think where we differ is that you see > unifying those layers in one syntax as a good thing, but I see it as a > bad thing: I think it is useful to be able to look at the code, and > understand which parts are definitely going to be enforced by the > runtime-checked layer. > > I think the ideal is to somehow create a standardised syntax for the > SA-checked layer, but still keep it separate from the syntax for the > runtime-checked layer. If neither docblocks nor attributes are a good > basis for that, maybe there's some other primitive we can add so that > users can mark both the "runtime type" and the "SA type". > > Straw man example: > > class Foo ~~Foo<T> extends Bar ~~Bar<int,T> { > public function foo(string ~~non-empty-string $in): array ~~list<T> { > ... > > > Maybe PHP would process the syntax, but not the semantics, of the extra > type information; SA tools would then be free to invent new pseudotypes > within that framework, without needing to wait for a full PHP release > cycle every time.
This feels like it would be isomorphic to attributes, no? Syntax validated, available via reflection, but nothing done with it. For me, my big concern is issues like what Anna Filina raised on Mastodon: https://phpc.social/deck/@afilina/116575372360401330 Every time we tighten up a permissive/unenforced rule to make it enforced, we alienate someone who was (wrongly? but innocently) taking advantage of that unenforcement. For the most part I have still supported those efforts, but we have to be mindful and careful with them. Adding another permissive/unenforced rule makes me nervous. (And as has been stated repeatedly, no, "everyone who matters uses SA" is not an answer.) I'm OK with this RFC not solving every type system concern ever. non-empty-string et al, I'm fine leaving for another time, as there's a number of ways to go about them. It's the "do this but we won't enforce it, yet, maybe we will later" landmine that concerns me. What would be convincing to me is 1. Actual data on SA usage vs generics. 2. Some proofs of concept that the problem is smaller than I am making it out to be, eg, "here's why almost no one will be dumb enough to put the wrong typed variable on the same line as the conflicting type declaration." 3. Some data on PHPStorm's market share, since in practice that would count as an SA tool (and would presumably give you a red squiggle every time you got it wrong). Or something along those lines. As I noted in my recent blog post, the million dollar question is "what is our threshold for how many people we're OK shooting themselves in the foot with this gap, and is the predicted number that actually will below that threshold?" That's the issue that needs to be addressed, IMO. --Larry Garfield

Rowan Tommins [IMSoP]

109 days ago
On 15 May 2026 17:11:22 BST, Larry Garfield <larry@garfieldtech.com> wrote:
>> Straw man example: >> >> class Foo ~~Foo<T> extends Bar ~~Bar<int,T> { >> public function foo(string ~~non-empty-string $in): array ~~list<T> { >> ... >> >> >> Maybe PHP would process the syntax, but not the semantics, of the extra >> type information; SA tools would then be free to invent new pseudotypes >> within that framework, without needing to wait for a full PHP release >> cycle every time. > >This feels like it would be isomorphic to attributes, no? Syntax validated, available via reflection, but nothing done with it.
Yeah, the basic concept is "attributes on types", but with a more concise syntax. There are, I think, three basic cases such a syntax would need to handle: - the "SA type" is the "runtime type" qualified by a generic parameter, either placeholder or concrete: e.g. Collection vs Collection<T> or Collection<int>, array vs array<Foo> - more generally, the "SA type" is the "runtime type" with extra rules applied: e.g. string vs non-empty-string - the "SA type" is something that just can't be expressed at all, and the "runtime type" has to be "mixed" or some other broad type: e.g. "function foo(): T", where T is a type parameter on a generic class. For the first two, you can just suffix the extra information rather than repeating yourself, so more like this: class Foo~<T> extends Bar~<int,T> { public function foo(string~non-empty $in): array~<T> { Other than properties, annotating with "mixed" is redundant anyway, so you could have this public function bar(~T): ~T { But if you wanted to enforce the lower bound, you'd have to manually include it: public function bar(int~T): int~T { It's obviously not as elegant as making every type string reserved natively, but it allows for much faster iteration in SA tools, and could replace all their use of docblocks over night. As and when the language added full runtime support for those types, users could just delete the ~ (or whatever delimiter we used) and opt in to enforcement. "class Foo~<T>" would be an erased generic, "class Foo<T>" would be a reified or monomorphized one. Regards, Rowan Tommins [IMSoP]

Seifeddine Gmati

108 days ago
Hi Rowan,
> If you write a function with no native type information, but an "@return int" docblock, PHPStan will report an error for a missing return statement at Level 0, and for an incorrect return statement on Level 3. > > There's no relationship between the syntax needed and the types of analysis performed.
You're right that I overstated this, PHPStan verifies many docblock annotations at low levels, not just at level 9. The claim narrows correctly for *generic* annotations specifically: `@template`, `@template-extends`, `@implements`-with-generic-args, and the parametric relationships they introduce. Those tend to live at higher levels because PHPStan has no native syntax to cross-reference against, so the checks must make assumptions about user intent that warrant a higher confidence threshold. The shift after this RFC is exactly that cross-reference: once `class Box<T : object>` is in syntax, the arity, bound, and inheritance checks are language-level violations that any tool reports at any level. Ondřej confirmed that PHPStan will promote generic violations to baseline checks once they're native. Mago already has them as baseline errors (we don't have levels). I'm not certain about Psalm, but I'd be surprised if Daniil disagreed.
> As Daniil pointed out, SA tools analyse code with offline parsers, not by loading and reflecting it; so the native enforcement of arity etc will still need to be reimplemented in each tool.
Both paths exist in every SA tool. Parsers handle the project source. Reflection handles classes for which the tool doesn't have source (e.g., built-ins, extensions). Psalm has explicit Reflection-based class registration: https://github.com/vimeo/psalm/blob/6ff4aa2b472a5a4dd16a75c807da633d2c2e9368/src/Psalm/Internal/Codebase/Reflection.php#L57 Mago and PHPStan have equivalent layers. The RFC's Reflection API serves the second path; tool parsers continue to serve the first. Both paths need updating to use native generic syntax. That work is real, and the maintainers above have committed to doing it once the RFC lands.
> The RFC will act as a standardisation of what tools *should* enforce around those things; but that could equally be done by agreeing a set of conformance tests based on the existing docblock syntax.
This has been tried. JetBrains explicitly attempted to bring the SA tool maintainers together around 2020 to produce a conformance spec for docblock generics; Brent referenced it in his email on this list. The attempt failed. The reason is structural: docblock generic syntax has no governance mechanism, no shared standard to anchor to, no enforcement path (a tool that ignores the spec faces no consequences), and the tools have already diverged on edge cases that would require breaking changes to reconcile. Tag aliases differ (`@extends` vs `@template-extends`), bound resolution differs, variance support differs. The only governance body that the ecosystem actually treats as authoritative is internals. That's the structural fact behind this RFC: not just "syntax in PHP is intrinsically better than syntax in docblocks" (which is true), but "the standardisation step that would make docblock generics consistent across tools requires authority no party in the ecosystem currently has."
> I think the ideal is to somehow create a standardised syntax for the SA-checked layer, but still keep it separate from the syntax for the runtime-checked layer. [...] Straw man example: class Foo ~~Foo<T> extends Bar ~~Bar<int,T>
The visual-distinction argument cuts both ways too. You're arguing that `~~` makes the SA-checked layer visible to the reader. But the same argument applies to `<...>`: anything in angle brackets is the generic type layer, anything outside is the runtime-checked layer. Once the convention is established (which TypeScript, Hack, Kotlin, Scala, Swift, C++, Java, and C# have already established), the distinction is just as visible as a `~~` prefix would be, without adding a new syntactic primitive PHP doesn't have anywhere else. Cheers, Seifeddine.

Rowan Tommins [IMSoP]

108 days ago
On 16 May 2026 02:01:04 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:
>The only governance body that the ecosystem actually treats as >authoritative is internals. That's the structural fact behind this >RFC: not just "syntax in PHP is intrinsically better than syntax in >docblocks" (which is true), but "the standardisation step that would >make docblock generics consistent across tools requires authority no >party in the ecosystem currently has."
I think this is a part I'm fuzzy on: which of the inconsistencies that currently exist are actually solved by the parts of this RFC which are enforced? Will users still get different results in different tools for the parts that are not enforced natively, or is PHP Internals going to become a standards body defining conformance tests for those parts?
>The visual-distinction argument cuts both ways too. You're arguing >that `~~` makes the SA-checked layer visible to the reader. But the >same argument applies to `<...>`: anything in angle brackets is the >generic type layer, anything outside is the runtime-checked layer.
I think you missed the point of the suggestion, which was to allow *all* unchecked type information to be included in the same place. Every single docblock annotation for guiding SA tools would be moved next to the "real" type it was qualifying. The base shape of the syntax would be standardized by PHP, but the semantics would be open to innovation and (dis)agreement by tool authors. It's also not true that <> is always present to make the distinction with generics; where the type parameter is *used*, it just looks like a real type: public function hasEdge(TNode $from, TNode $to): bool The actual checked type there is invisible - the "bound erasure" means that it might be just "mixed", but might be some base interface. Rowan Tommins [IMSoP]

Seifeddine Gmati

107 days ago
Hi Rowan, The parts this RFC enforces (generic declaration syntax, type-parameter resolution, inheritance arity, bound conformance, parametric LSP, variance soundness, and turbofish arity) are exactly where static analysis tools currently disagree most. For evidence that these inconsistencies are real, you can look at the issue trackers for Mago, PHPStan, or Psalm. Even basic syntax varies across tools today: some use `@template T of X` while others use `@template T as X`. Defaults and inference are also major categories of disagreement. For example, `new Collection([1])` could be inferred as several different types (`Collection<1>`, `Collection<int>`, `Collection<scalar>`, `Collection<mixed>`, `Collection<numeric>`); as a maintainer, there isn't always a "correct" answer. Native syntax like `new Collection::<int>([1])` makes user intent explicit, forcing tools to converge on the same answer. The disagreement isn't limited to edge cases or inference. Even fundamental correctness questions divide the tools. Consider this diamond pattern: ```php /** @template T */ interface Example { /** @param T $v @return T */ public function produce(mixed $v): mixed; } /** @extends Example<A> */ interface AExample extends Example {} /** @extends Example<B> */ interface BExample extends Example {} class X implements AExample, BExample { /** @param A|B $v @return A */ #[Override] public function produce(mixed $v): A { /* ... */ } } ``` The correct return type here is `A & B`. The implementer must satisfy both `AExample::produce(A): A` and `BExample::produce(B): B`, which forces the parameter to be `A | B` (LSP widening on contravariant position) and the return to be `A & B` (LSP narrowing on covariant position to the intersection). Anything narrower violates one parent contract: returning just `A` breaks the `BExample` contract; returning just `B` breaks the `AExample` contract. Mago and PHPStan both accept `A` and `B` as return types: both are wrong. Psalm rejects `A` but accepts `B`: also wrong, and inconsistently so. Three tools exhibit three different incorrect behaviors on the same pattern. Under this RFC, the diamond-merge rule rejects both at compile time and forces the implementer to declare `A & B`, which is the only sound choice. - https://mago.carthage.software/1.27.1/en/playground/#019e32b2-a082-7482-7ad2-8ecc0f29e120 - https://phpstan.org/r/5044aa92-c599-45a3-ad24-c735db44a125 - https://psalm.dev/r/cf5d2dadd9 If the tool authors haven't independently converged on the correct answer, "agree on a conformance spec" isn't the lightweight coordination it sounds like. The disagreement isn't on edge cases; it's on what the *correct* answer is, and the people running the tools are the ones in the best position to know. ref: - https://github.com/carthage-software/mago/issues/899 (Mago recently added default support; Psalm still lacks this) - https://github.com/carthage-software/mago/issues/1859 (Mago disagreeing with Psalm and PHPStan; solved if we could have `->where::<int>($c, $o, $v)` and not rely on inference) - https://github.com/phpstan/phpstan/issues/12978 (solved with explicit turbofish) - https://github.com/vimeo/psalm/issues/7496 (RFC makes it clear that intersection works with generics if the bound is object or more specific) - https://github.com/vimeo/psalm/issues/5910 (fixed with explicit turbofish) If I went all day trying to find issues solved by this RFC, or inconsistencies between tools, this thread wouldn't end. For the parts the RFC doesn't enforce, such as flow analysis and type narrowing, tools will continue to handle them differently. However, native syntax narrows the surface of disagreement. Tools will start from the same parsed structure and expectations, rather than diverging early on what a docblock like `@template T = mixed` even means. Internals is not becoming a standards body for unenforced parts; tools will continue to compete on inference and narrowing quality by design. Regarding your suggestion to include all unchecked type information in one place: adding a syntactic tier that PHP parses but ignores would be a new architectural pattern. Beyond that, it creates its own coordination problem. If `~~lowercase-string` is parsed by PHP but interpreted by tools, any disagreement on semantics (e.g., whether `""` is lowercase, whether the rule is ASCII or multibyte, whether numeric strings count) remains. PHP would host syntax it doesn't understand, and the ecosystem would still lack a governance mechanism for those semantics. making it truly 100% useless. On the visibility of type parameters like `TNode`: it's true they can look like concrete types at use sites. However, this is consistent with languages like Java, Rust, and TypeScript. Conventions like single uppercase letters or PascalCase (e.g. `TKey`, `TNode`) are already well-established in the PHP ecosystem through `@template`. Native syntax promotes these established signals into the language. Cheers, Seifeddine.

Rob Landers

107 days ago
On Sat, May 16, 2026, at 23:32, Seifeddine Gmati wrote:
> Hi Rowan, > > For evidence that these inconsistencies are real, you can look at the issue trackers for Mago, PHPStan, or Psalm. Even basic syntax varies across tools today: some use `@template T of X` while others use `@template T as X`. Defaults and inference are also major categories of disagreement. For example, `new Collection([1])` could be inferred as several different types (`Collection<1>`, `Collection<int>`, `Collection<scalar>`, `Collection<mixed>`, `Collection<numeric>`); as a maintainer, there isn't always a "correct" answer. Native syntax like `new Collection::<int>([1])` makes user intent explicit, forcing tools to converge on the same answer. > > The disagreement isn't limited to edge cases or inference. Even fundamental correctness questions divide the tools. Consider this diamond pattern: > > ```php > /** @template T */ > interface Example { > /** @param T $v @return T */ > public function produce(mixed $v): mixed; > } > > /** @extends Example<A> */ interface AExample extends Example {} > /** @extends Example<B> */ interface BExample extends Example {} > > class X implements AExample, BExample { > /** @param A|B $v @return A */ > #[Override] > public function produce(mixed $v): A { /* ... */ } > } > ``` > > The correct return type here is `A & B`. The implementer must satisfy both `AExample::produce(A): A` and `BExample::produce(B): B`, which forces the parameter to be `A | B` (LSP widening on contravariant position) and the return to be `A & B` (LSP narrowing on covariant position to the intersection). Anything narrower violates one parent contract: returning just `A` breaks the `BExample` contract; returning just `B` breaks the `AExample` contract. > > Mago and PHPStan both accept `A` and `B` as return types: both are wrong. Psalm rejects `A` but accepts `B`: also wrong, and inconsistently so. Three tools exhibit three different incorrect behaviors on the same pattern. Under this RFC, the diamond-merge rule rejects both at compile time and forces the implementer to declare `A & B`, which is the only sound choice.
This example is well chosen but I think it’s doing more rhetorical work than the mechanism supports, and that matters for the “forces tools to converge” claim. If I’m reading the RFC correctly, the diamond-merge check fires at link time when a concrete class derives from two generic parents instantiated at different type arguments. Outside that specific structural pattern, T erases to its bound and the engine has no opinion on whether a parametric position reads as intersection or union. Which means Box<A&B> works either way you want it to work. It could mean A|B or A&B; the engine happily accepts either reading. PHPStan users internalize one rule, Psalm users another, Mago users a third, each reinforced by their tool of choice and by passing tests because the runtime never surfaces the disagreement. They won’t know they’re “holding it wrong” until someone writes class X implements AExample, BExample. At that point the rejection lands not on the upstream interface author whose parametric design forced the constraint, but on the downstream implementer trying to satisfy both contracts … possibly years after the original contract was written. I’m not arguing the engine check is wrong; it’s a real win at the points where it does fire. But “forces tools to converge” overstates it. The convergence is local to specific link-time checkpoints. Between them, SA tools keep diverging on inference and parametric semantics, and developers keep learning the type system from whichever tool they happen to use. That’s a real dev-UX problem, IMHO.

Seifeddine Gmati

106 days ago
Hi Rob, The RFC enforces type-system structure at four places: declaration-site syntax and variance soundness, link-time inheritance arity and bound conformance, link-time parametric LSP (including the diamond-merge case I described), and runtime turbofish arity and bounds. The structural enforcement is substantial: the variance declared at the parameter (`+T`, `-T`, invariant by default) constrains how the parameter can be used, parametric LSP substitutes the bindings into method signatures at inheritance points, and turbofish forces explicit type arguments at call sites requiring disambiguation. What's not enforced is parametric flow analysis: tracking how a `T`-typed value flows through a method's body, narrowing it through control flow, and inferring its concrete type at a use site. That's the layer where tools currently diverge most, and you're right that this RFC doesn't directly resolve those disagreements. But the RFC does change the *shape* of the inference-disagreement problem in a way that helps. Today, tools have to guess what `new Collection([1])` means because there's no syntax to express the user's intent. The guesses differ. Once turbofish exists, tools can do two things they can't do now: 1. Simplify inference to only handle the unambiguous cases (where the type is clearly determinable from context and conventions), and emit a warning or error when inference would otherwise have to guess. 2. Recommend turbofish at sites where the user's intent is ambiguous, so the user can disambiguate explicitly with `new Collection::<int>([1])`. That moves the dev-UX problem from "different tools silently produce different inferred types" to "tools agree that the type is ambiguous, recommend turbofish, and the user disambiguates." The convergence point becomes the user's annotation, not the tool's heuristic. As Mago's maintainer I can tell you we'd lean into this shift hard. Our current inference heuristics are messy precisely because there's no other option; the moment turbofish exists, we'd simplify them to "be sure or ask the user."I'd expect PHPStan and Psalm to land in similar places eventually too.
> Which means Box<A&B> works either way you want it to work. It could mean
A|B or A&B; the engine happily accepts either reading. Disagree. The reading of `Box<A&B>` is determined by Box's variance declaration. If `Box<+T>` declares T as covariant, then `Box<A&B>` is a subtype of `Box<A>` and `Box<B>` (intersection narrows for covariant positions). If `Box<-T>` declares T as contravariant, the relationship inverts: `Box<A&B>` is a supertype, and a `Box<A>` can be passed where `Box<A&B>` is expected. If T is invariant (the default), neither relationship holds and the engine treats `Box<A&B>` as a distinct type from `Box<A>` and `Box<B>`. So the engine has a definite reading; it just depends on the variance declared at Box. The interpretation isn't ambiguous, it's compositional. Tools that disagreed today on what `Box<A&B>` means would have to agree once variance is declared in syntax, because the variance is then a property of the language, not a tool-specific interpretation. I'd grant that for the common case of invariant T (which is the default and what most generic code will use) the type argument is opaque relative to subtyping, for example, `Box<A&B>` is not a subtype of `Box<A>` or `Box<B>`. That opacity is the bound-erasure trade. But it's a determinate opacity, not a "happily accepts either reading" one. Cheers, Seifeddine.

Rob Landers

106 days ago
On Mon, May 18, 2026, at 01:36, Seifeddine Gmati wrote:
> Hi Rob, > > The RFC enforces type-system structure at four places: declaration-site syntax and variance soundness, link-time inheritance arity and bound conformance, link-time parametric LSP (including the diamond-merge case I described), and runtime turbofish arity and bounds. The structural enforcement is substantial: the variance declared at the parameter (`+T`, `-T`, invariant by default) constrains how the parameter can be used, parametric LSP substitutes the bindings into method signatures at inheritance points, and turbofish forces explicit type arguments at call sites requiring disambiguation. > > What's not enforced is parametric flow analysis: tracking how a `T`-typed value flows through a method's body, narrowing it through control flow, and inferring its concrete type at a use site. That's the layer where tools currently diverge most, and you're right that this RFC doesn't directly resolve those disagreements. > > But the RFC does change the *shape* of the inference-disagreement problem in a way that helps. Today, tools have to guess what `new Collection([1])` means because there's no syntax to express the user's intent. The guesses differ. Once turbofish exists, tools can do two things they can't do now: > > 1. Simplify inference to only handle the unambiguous cases (where the type is clearly determinable from context and conventions), and emit a warning or error when inference would otherwise have to guess. > 2. Recommend turbofish at sites where the user's intent is ambiguous, so the user can disambiguate explicitly with `new Collection::<int>([1])`. > > That moves the dev-UX problem from "different tools silently produce different inferred types" to "tools agree that the type is ambiguous, recommend turbofish, and the user disambiguates." The convergence point becomes the user's annotation, not the tool's heuristic. As Mago's maintainer I can tell you we'd lean into this shift hard. Our current inference heuristics are messy precisely because there's no other option; the moment turbofish exists, we'd simplify them to "be sure or ask the user."I'd expect PHPStan and Psalm to land in similar places eventually too. > > > Which means Box<A&B> works either way you want it to work. It could mean A|B or A&B; the engine happily accepts either reading. > > Disagree. The reading of `Box<A&B>` is determined by Box's variance declaration. If `Box<+T>` declares T as covariant, then `Box<A&B>` is a subtype of `Box<A>` and `Box<B>` (intersection narrows for covariant positions). If `Box<-T>` declares T as contravariant, the relationship inverts: `Box<A&B>` is a supertype, and a `Box<A>` can be passed where `Box<A&B>` is expected. If T is invariant (the default), neither relationship holds and the engine treats `Box<A&B>` as a distinct type from `Box<A>` and `Box<B>`. > > So the engine has a definite reading; it just depends on the variance declared at Box. The interpretation isn't ambiguous, it's compositional. Tools that disagreed today on what `Box<A&B>` means would have to agree once variance is declared in syntax, because the variance is then a property of the language, not a tool-specific interpretation. > > I'd grant that for the common case of invariant T (which is the default and what most generic code will use) the type argument is opaque relative to subtyping, for example, `Box<A&B>` is not a subtype of `Box<A>` or `Box<B>`. That opacity is the bound-erasure trade. But it's a determinate opacity, not a "happily accepts either reading" one. > > Cheers, > Seifeddine.
Hi Seifeddine, I'm a bit confused. In your earlier email you said that tools currently can't converge on simple generics correctness (and in your last email admitted even your own tool was incorrect), but say that this will force them to converge. If they've not been able to do so already, I have little faith that they will in the future. In fact, if someone could get them in a room together to converge, maybe we wouldn't need this RFC at all. In regards to my Box<A&B> example. My apologies. I was collapsing two different perspectives into one instead of being clear. I'm not talking about the engine; I think your RFC is clear there. I'm talking about new users or users not using any tooling to tell them they've got an error. T-typed positions inside Box are checked against T's bound, not against A&B. So Box<A&B>'s internal value flow is observationally identical to Box<A|B>'s, or Box<mixed>'s -- all accept and produce values satisfying the bound. The static subtyping relationship is real and enforced at link-time parametric LSP. It's invisible at runtime. I understand this is the entire point of "type erasure", but it's a real problem for new developers using PHP for the first time. A developer reasoning at the value level gets no engine feedback distinguishing intersection from union from arbitrary supertype. Further, the runtime is silent in cases the RFC documents as enforced. The Limitations section says: "the inherited or trait-imported function's signature is substituted on the child - reflection sees the substituted types, the link-time variance check uses them, and entry-side type checks inside the body read the substituted parameter types." Tested on your PR branch: class Box<T> { public function test(T $v): T { return $v; } } class IntBox extends Box<int> {} $b = new IntBox(); echo $b->test("hello"); // prints "hello" Reflection reports `int` for the parameter type on `IntBox::test`, confirming the metadata is substituted. But the entry-side check accepts the string, contradicting the same sentence's claim that those checks read substituted parameter types. The implementation does the metadata half and skips the runtime-enforcement half, and the runtime-enforcement half is the part that would catch the bug. The RFC text and the implementation disagree. One thing I want to call out: the RFC template specifically asks you to address PHP's "wide range of users" and consider "the consequences of making the learning curve steeper with every new feature." The RFC engages with that question for developers already inside the @template ecosystem by formalizing existing practice, shortening code, reducing two-parallel-type-systems cognitive load. What I don't see addressed anywhere is the new-user case: a developer who's never seen generics encounters <T> in production code, writes their own, runs it, gets no runtime feedback when their value-level intuition diverges from the underlying expectations without using a tool. The IntBox case above demonstrates the gap empirically: the developer writes what looks like reasonable code, refreshes, and the output looks correct despite being wrong by the engine's own claimed semantics. The template's procedural ask is engagement with the full picture; what's been delivered is engagement with the favorable half and redirection throughout this thread to narrow the audience to only "the @template ecosystem". I would vote "no" on this RFC as-is; it is too niche. It claims it is not niche with "200k files" using @template, on github, focused on some of the important frameworks for PHP, but not all of them. 200k files is a drop in the bucket when you've worked on php repos spanning millions of files. It doesn't address ALL php developers and from my example above, seems to be actively harmful to new developers. — Rob

Rowan Tommins [IMSoP]

106 days ago
On 16 May 2026 22:32:30 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:
>Hi Rowan, > >The parts this RFC enforces (generic declaration syntax, type-parameter >resolution, inheritance arity, bound conformance, parametric LSP, variance >soundness, and turbofish arity) are exactly where static analysis tools >currently disagree most.
Thanks. It sounds like there's a few different things: Some differences are directly resolved by the proposal; most notably, everything around syntax and keyword naming, since this would be fully standardised in PHP. In some cases, the problem remains, but the RFC gives the *user* power to remove the ambiguity; e.g. inference has no one right answer, and explicit call-site syntax means users don't have to rely as much on how tools approach it. I can see why that is hard to do without new and concise syntax. In other cases, it sounds like the correct answer is actually an established fact of type theory - e.g. the behaviour of covariance and diamond inheritance. In those cases, the value of the RFC is more subtle, because all it's doing is lending extra urgency to fixing something that everyone should agree is a bug.
>Regarding your suggestion to include all unchecked type information in one >place: adding a syntactic tier that PHP parses but ignores would be a new >architectural pattern.
That is exactly my concern with your proposal! Behind the jargon, I think the long list of things enforced and not enforced boils down to: - the minimum bound of a type parameter is propagated automatically as the run-time type - the behaviour of extending a generic class or implementing a generic interface is enforced - the behaviour of passing a value to a generic function parameter, return type, etc, is completely unenforced; the language just provides syntax for SA tools to hang their own semantics onto In many cases, none of the enforced parts actually apply, so this: function<T> wrap(T $item): SomeDataStructure<T> would be entirely equivalent, as far as PHP is concerned, to this: function wrap($item): SomeDataStructure All of the rest is just "a syntactic tier that PHP parses but ignores".
>Beyond that, it creates its own coordination >problem. If `~~lowercase-string` is parsed by PHP but interpreted by
tools,
>any disagreement on semantics (e.g., whether `""` is lowercase,
whether the
>rule is ASCII or multibyte, whether numeric strings count) remains. PHP >would host syntax it doesn't understand, and the ecosystem would still
lack
>a governance mechanism for those semantics. making it truly 100% useless.
If the implementation was actually enforced by PHP, it would not use the extra syntax. If the implementation was *not* enforced by PHP, tools would have to choose how to implement it; either individually, or through collective decision-making between tools. It would make no difference whether PHP spelled the type as "lowercase-string" or "string~~lowercase". The biggest advantage I see is the speed of innovation - I've seen ideas for new pseudo-types floated on this list, or on social media, and an implementation available in tools within days or weeks. Users can then start using it immediately. A full implementation in the core language would wait for the next annual release; and then for users to reach that level as their minimum supported. I thought tools might welcome a way to move away from docblock-parsing without giving up that ability to innovate new features.
>On the visibility of type parameters like `TNode`: it's true they can look >like concrete types at use sites. However, this is consistent with >languages like Java, Rust, and TypeScript. Conventions like single >uppercase letters or PascalCase (e.g. `TKey`, `TNode`) are already >well-established in the PHP ecosystem through `@template`. Native syntax >promotes these established signals into the language.
I wasn't making any comment about how type parameters are spelled. I was responding specifically to this sentence in your previous e-mail:
> But the same argument applies to `<...>`: anything in angle brackets > is the generic type layer, anything outside is the runtime-checked > layer.
I was simply pointing out that some things in the "generic type layer" are not marked by angle brackets, so in the syntax proposed by the RFC, there is no visual signal that these are unenforced types. Regards,
-- Rowan Tommins [IMSoP]

Seifeddine Gmati

106 days ago
Hi Rowan,
> In other cases, it sounds like the correct answer is actually an
established fact of type theory - e.g. the behaviour of covariance and diamond inheritance. Sharper than it looks. The substitution conflict has existed in PHP all along, just in an indirect form: ```php interface A1 extends Box<A> {} // via @extends docblock interface B1 extends Box<B> {} // via @extends docblock class C implements A1, B1 {} // legal PHP, conflicting substituted prototypes ``` Structurally identical to `class C implements Box<A>, Box<B>` from the substitution view. SA tools should have caught this years ago, but they didn't. I discovered the bug in Mago while writing the RFC. The direct form is a compile error today (same-interface-twice), so tools never had a forcing function to consider the substituted-conflict case, and the indirect form slipped through. Native generics enable the direct form possible, which forces the diamond-merge check into the engine, and that machinery consequently covers the indirect form as a side effect.
> Behind the jargon, [...] All of the rest is just "a syntactic tier that
PHP parses but ignores". Two pieces missing from that framing: Reflection metadata. The pre-erasure form is exposed as introspectable data. Frameworks, DI containers, ORMs, serializers can read generic information directly from the engine rather than reparsing source. It's first-class structured data that the language exposes. The bound-as-runtime-type substitution. `class UserCollection extends Collection<User>` substitutes T = User into every T-typed parameter and return; those positions become User-typed at runtime, enforced by the engine. That's real type-checking work.
> The biggest advantage I see is the speed of innovation [...]
The `~~Type` proposal raises two questions I don't see clean answers to: 1. *Lexical grammar inside `~~`?* Whitespace breaks conditional types (`$var is Foo ? A : B`). `$` breaks variable-using expressions. `{` breaks array shapes. `<` breaks generics. Every plausible stopping rule breaks a class of type expressions SA tools currently support. 2. *AST shape?* Either an opaque string (tools still parse it themselves, still disagree, gain over docblocks is just location) or structured (PHP has committed to an AST for type expressions it doesn't enforce, which is the architectural pattern you wanted to avoid). The speed advantage of docblocks comes from PHP not parsing them at all. Moving syntax into the language reduces that velocity regardless of whether PHP enforces semantics. The RFC's trade-off is to move the *enforceable* parts into the standard and leave the *innovation* parts in docblocks. Future RFCs will incrementally move what's stable enough to standardize, the docblock channel stays open for fast iteration.
> I was simply pointing out that some things in the "generic type layer"
are not marked by angle brackets [...] Fair, I was answering a different question. At use sites a parameter `TNode $from` doesn't carry an indicator that `TNode` is a type parameter. That's true of every generic-supporting language; conventions (uppercase-first, T-prefix) emerge to signal it, and the PHP ecosystem already follows them through `@template`. But you're right that the syntactic marker is at the declaration, not at every use. Cheers, Seifeddine.

Larry Garfield

106 days ago
On Mon, May 18, 2026, at 3:33 AM, Seifeddine Gmati wrote:
> The `~~Type` proposal raises two questions I don't see clean answers to: > > 1. *Lexical grammar inside `~~`?* Whitespace breaks conditional types > (`$var is Foo ? A : B`). `$` breaks variable-using expressions. `{` > breaks array shapes. `<` breaks generics. Every plausible stopping rule > breaks a class of type expressions SA tools currently support. > > 2. *AST shape?* Either an opaque string (tools still parse it > themselves, still disagree, gain over docblocks is just location) or > structured (PHP has committed to an AST for type expressions it doesn't > enforce, which is the architectural pattern you wanted to avoid). > > The speed advantage of docblocks comes from PHP not parsing them at > all. Moving syntax into the language reduces that velocity regardless > of whether PHP enforces semantics. The RFC's trade-off is to move the > *enforceable* parts into the standard and leave the *innovation* parts > in docblocks. Future RFCs will incrementally move what's stable enough > to standardize, the docblock channel stays open for fast iteration.
Rowan's "erased user-extensible type system" is an interesting idea, and I think it's worth exploring. But I don't think this RFC is the place for it. This is a partially-enforced-generics proposal, so using an "ignored by the engine" flag for its syntax would make things more confusing, not less. I'd rather move the discussion of a user-extensible type system to its own discussion. I don't think it's necessary for generics, which are already extensible in a well-defined and industry-standard way. --Larry Garfield

Rowan Tommins [IMSoP]

105 days ago
On 18/05/2026 09:33, Seifeddine Gmati wrote:
> > Behind the jargon, [...] All of the rest is just "a syntactic tier > that PHP parses but ignores". > > Two pieces missing from that framing: > > Reflection metadata. The pre-erasure form is exposed as introspectable > data. Frameworks, DI containers, ORMs, serializers can read generic > information directly from the engine rather than reparsing source. > It's first-class structured data that the language exposes.
I'd vaguely included this under "provides syntax for SA tools to hang their own semantics onto". However, this reflection visibility is exactly the part I was thinking about how to provide with a dedicated syntax.
> The bound-as-runtime-type substitution. `class UserCollection extends > Collection<User>` substitutes T = User into every T-typed parameter > and return; those positions become User-typed at runtime, enforced by > the engine. That's real type-checking work.
Gotcha. Am I right in thinking this part is effectively the same as the "monomorphized generics" in Larry & Gina's blog post?
> The `~~Type` proposal raises two questions I don't see clean answers to: > > 1. *Lexical grammar inside `~~`?* Whitespace breaks conditional types > (`$var is Foo ? A : B`). `$` breaks variable-using expressions. `{` > breaks array shapes. `<` breaks generics. Every plausible stopping > rule breaks a class of type expressions SA tools currently support.
I did say this was straw man syntax :) However, I don't think there's any problem with nesting brackets - I'm not aware of any restriction on using [] array literals inside #[] attributes, for instance. Perhaps this is really the same as the next question...
> 2. *AST shape?* Either an opaque string (tools still parse it > themselves, still disagree, gain over docblocks is just location) or > structured (PHP has committed to an AST for type expressions it > doesn't enforce, which is the architectural pattern you wanted to avoid).
My thinking was that the general syntax would be completely parsed, and put into a suitable structure in reflection, but without any semantic rules. The minimum needed for generics would be: - all existing types, including namespace and alias resolution - an additional name resolution rule for generic type parameters, so that "T" inside "class Foo<T> {" doesn't get expanded to a namespace-qualified name - a rule for type<type list>, allowing any valid type both inside and outside Parse that into a general-purpose reflection structure, suitable not only for generics like "Foo<T>", but also things like "array<int>" Add a few more rules, and you can cover maybe 90% of the extra types PHPStan lists here: https://phpstan.org/writing-php-code/phpdoc-types - string and numeric literals - constant and class constant references - unexpanded pseudo-types, which could be "any name with at least one hyphen", similar to Custom HTML Elements That would parse, for example, "non-empty-list<negative-number>", "int<1,100>", "class-string<T>", and "value-of<Type::ARRAY_CONST>" The next obvious thing to add would be array shapes - again, just standardising and parsing the syntax, and creating a stable reflection API which tools could use. The syntax marker would just be to alert users that this was a different category of type information, which couldn't be relied on without external tooling.
-- Rowan Tommins [IMSoP]

Craig Francis

109 days ago
On 15 May 2026, at 00:32, Seifeddine Gmati <azjezz@carthage.software> wrote:
> PHP's type system has grown one feature per RFC for a decade: scalar types, union types, intersection types, DNF, true/false/null types. None of those shipped the entire wishlist either. `class-string<T>`, integer ranges, non-empty-string, negated types, and literal types can each be their own future RFC.
On this point; I still need to finish the RFC for a `literal-string` type, which already exists in Psalm and PHPStan. I've been using Joe Watkins' is_literal() implementation with 3 big production websites for some time, and Robert Landers has a prototype implementation as a proper type[1]. I just thought I'd give an example of a separate/small RFC. Craig [1] https://github.com/php/php-src/compare/master...bottledcode:php-src:add/literal-string-2

Daniil Gentili

109 days ago
> > > If one would actually use the highest possible level of the static > analysis tools they would need to “convince” the static analyzer that > “yes, unserialize() is actually returning an object of the right type”. > This is typically done with `assert($foo instanceof SomeClass);`,
> something that PHP will double-check for you at runtime.
Interesting that you mention specifically assert, which PHP actually does **not** double-check for you at runtime, in production by default, so there is actually a precedent for a language feature that only does typechecking in the development stage. My
> understanding based on the discussion is that the RFC specifically > excludes support for `instanceof SomeClass<SomeType>`, folks would need > to fall back to `/** @var … */` comments or mark the offending line as > ignored in some other way - which basically means that even *if* PHP > supported generic syntax, they would need those doc comments.
While this could be a valid remark, in practice @var is considered harmful at least by Psalm, and overall I have never seen it used (with good reasons) in generic production code, simply because if your code needs to know the specific types of some generic bounds, you would have already specified them in parameter/property typehints (statically guaranteeing all consumers that require specific bound types are passed that bound); if not, there's no point in using @var at all, and you can just keep using the generic type (appropriately specifying it in returned types and other outputs). Kind regards, Daniil Gentili.

Levi Morrison

109 days ago
On Fri, May 15, 2026 at 6:11 AM Daniil Gentili <daniil.gentili@gmail.com> wrote:
>> >> >> If one would actually use the highest possible level of the static >> analysis tools they would need to “convince” the static analyzer that >> “yes, unserialize() is actually returning an object of the right type”. >> This is typically done with `assert($foo instanceof SomeClass);`, >> >> >> something that PHP will double-check for you at runtime. > > > > Interesting that you mention specifically assert, which PHP actually does **not** double-check for you at runtime, in production by default, so there is actually a precedent for a language feature that only does typechecking in the development stage.
Assertions have been on by default for a long time, and in 8.0 we elevated them to throw instead of warn. So by default, yes, in production you do get assertions.

Seifeddine Gmati

100 days ago
> - RFC: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969
Hello Internals, Status update: RFC is now at v0.22. Two changes are worth flagging. 1. Substituted runtime contracts are now enforced end-to-end on inherited members. A child class binding a generic parent now enforces the substituted parameter and return types at runtime at every site the substitution touches: method and constructor entry (including defaults and per-element variadics), return values, backed property storage, property hook get/set signatures, and trait-imported method and property types. Enforcement is uniform regardless of the parent's bound, a child binding T to int on Box<T : mixed> rejects non-int values everywhere, not just where the parent's bound happened to be stricter. This collapses the "body bytecode is not recompiled per substitution" caveat from earlier drafts. The specific case the previous Limitations text called out as observable (a virtual hook returning a hardcoded value of the wrong substituted type) now throws an error. The Limitations section is down to three bullets: type-argument erasure (Box<int> accepts a Box<string> because the runtime check is instanceof Box), turbofish doesn't tighten parameters, and method-level T-bound resolves to the bound rather than the per-instance instantiation. 2. Reflection: plural ancestor-binding getter. `ReflectionClass::getGenericArgumentsForParentInterface()` now returns `list<list<ReflectionType>>`. A class binding the same generic interface multiple times, gets every binding as a separate outer-list entry. The previous singular shape silently dropped all but one. ( e.g. `class Bar implements Foo<int>, Foo<string>`) Cheers, Seifeddine.

Matthew Brown

99 days ago
On Tue, 12 May 2026 at 10:07, 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: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969 > > Thanks, > Seifeddine. >
This is just from the perspective of a static analysis tool author, but I hope it's instructive: Currently every return, param type and property type requires two fields: the type in the signature and the type in the docblock. These can sometimes be different types, and each static analysis tool has its own way of dealing with the discrepancy. Furthermore, people will sometimes omit the docblock type definition on child class definitions of those methods, and static analysis tools need their own way to handle those discrepancies too. When I ported Psalm to run on Hack code (whose type structure mirrors this RFC) I was able to remove all of that code, which made the implementation much more straightforward and memory-efficient. I understand that improving the static analysis data model should not, on its own, be a reason to merge, but I just wanted to relate how much easier this model makes things. Best wishes, Matt

Derick Rethans

99 days ago
On Sun, 10 May 2026, Seifeddine Gmati wrote:
> 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 > - Implementation: https://github.com/php/php-src/pull/21969
I have just read through this well-put-together RFC again. It however does not change my opinion that I don't believe that having types declared, but not generally enforced, is a good idea. I have had several conversions with PHP users over the last few weeks, and although many would like generics, they were almost exclusively confused when it became clear these types weren't enforced. It would create another paradigm for PHP users, different from everywhere else where types are defined they are enforced. I therefore do not believe this is a way forward, and await what the result of https://thephp.foundation/blog/2025/08/05/compile-generics/ is going to turn out to be. with kind regards, Derick
-- https://derickrethans.nl | https://xdebug.org | https://dram.io Author of Xdebug. Like it? Consider supporting me: https://xdebug.org/support mastodon: @derickr@phpc.social @xdebug@phpc.social

Matteo Beccati

99 days ago
Hi Derick, Il 25/05/2026 17:28, Derick Rethans ha scritto:
> On Sun, 10 May 2026, Seifeddine Gmati wrote: > >> 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 >> - Implementation: https://github.com/php/php-src/pull/21969 > > I have just read through this well-put-together RFC again. It however > does not change my opinion that I don't believe that having types > declared, but not generally enforced, is a good idea. > > I have had several conversions with PHP users over the last few weeks, > and although many would like generics, they were almost exclusively > confused when it became clear these types weren't enforced. > > It would create another paradigm for PHP users, different from > everywhere else where types are defined they are enforced. > > I therefore do not believe this is a way forward, and await what the > result of https://thephp.foundation/blog/2025/08/05/compile-generics/ is > going to turn out to be.
FWIW, I share the same feelings. Cheers
-- Matteo Beccati

Matthew Brown

98 days ago
On Mon, 25 May 2026 at 11:29, Derick Rethans <derick@php.net> wrote:
> > I therefore do not believe this is a way forward, and await what the > result of https://thephp.foundation/blog/2025/08/05/compile-generics/ is > going to turn out to be. > >
That blog post is a year old, and the top comment on the attached Reddit thread captures my view on it:
> The problem with such an approach is that it locks generics to be
reified. If at some point PHP realizes it's not possible or feasible to implement reified generics for everything else (`new` construct, methods, compound types etc), then PHP will likely **never** implement erased generics for those missing features, or generics in general in PHP - due to backwards compatibility promise and/or consistency, and PHP will be stuck forever with half-baked feature that, arguably, doesn't even cover 50% of it's usage (although the article states it's 80%, I doubt that's true. In my experience generics in functions, methods as well as `new` instantiations account for more than half of all usages, if not much more).
> I would much rather have "full" support of erased generics, than a <50%
support of reified generics that will never get above that 50%. I would add to that: in 2004 adding runtime-checked parameter types at function boundaries made sense because there was no other widely-accepted way of checking those contracts. Adding checks at function boundaries made failures much easier to debug. It would take more than a decade before open-source static analysis tools began to be widely adopted by PHP developers — tools that can discover such bugs far earlier in the development process. In 2026 runtime-checked types don't make sense in all circumstances, not when static analysis tools can discover far more bugs, far earlier and faster, than runtime checks can ever hope to. The majority of new interpreted code being written today is in languages with erased generics. I feel that the decisions made in 2004 should not dominate decision-making today.

Rowan Tommins [IMSoP]

98 days ago
On 25 May 2026 20:26:41 BST, Matthew Brown <matthewmatthew@gmail.com> wrote:
>> https://thephp.foundation/blog/2025/08/05/compile-generics/ is > >That blog post is a year old, and the top comment on the attached Reddit >thread captures my view on it: > >> The problem with such an approach is that it locks generics to be >reified.
I think the two proposals have a lot more overlap than their framing suggests. Both block us from having completely erased generics, because they include *some* reification/monomorphization, but nothing in Gina/Larry's blog post would actually block Seifeddine's proposal as far as I can see. Specifically, if you take everything Seifeddine has implemented, but 1) restrict generic declarations to only interfaces and abstract classes; and 2) remove the "turbofish" syntax completely; then you end up basically with what the blog post suggests. The main difference of opinion is what to do with those "missing" parts: - Larry & Gina proposed just leaving then forbidden until we decide how to implement them - Seifeddine proposed locking in the syntax with some partial checking, but mostly just reflection support
>In 2026 runtime-checked types don't make sense in all circumstances, not >when static analysis tools can discover far more bugs, far earlier and >faster, than runtime checks can ever hope to.
Elsewhere on this thread, I've put forward some thoughts about having a specific syntax distinction between runtime-checked type information, and SA/reflection-only type information. I'd be interested in your thoughts on that approach. Rowan Tommins [IMSoP]

Larry Garfield

97 days ago
On Tue, May 26, 2026, at 8:12 AM, Rowan Tommins [IMSoP] wrote:
> On 25 May 2026 20:26:41 BST, Matthew Brown <matthewmatthew@gmail.com> wrote: > >> https://thephp.foundation/blog/2025/08/05/compile-generics/ is >> >>That blog post is a year old, and the top comment on the attached Reddit >>thread captures my view on it: >> >>> The problem with such an approach is that it locks generics to be >>reified. > > > I think the two proposals have a lot more overlap than their framing > suggests. Both block us from having completely erased generics, because > they include *some* reification/monomorphization, but nothing in > Gina/Larry's blog post would actually block Seifeddine's proposal as > far as I can see. > > Specifically, if you take everything Seifeddine has implemented, but 1) > restrict generic declarations to only interfaces and abstract classes; > and 2) remove the "turbofish" syntax completely; then you end up > basically with what the blog post suggests. > > > The main difference of opinion is what to do with those "missing" parts: > > - Larry & Gina proposed just leaving then forbidden until we decide how > to implement them > - Seifeddine proposed locking in the syntax with some partial checking, > but mostly just reflection support
I would agree. Seif's proposal is effectively a superset of what Gina was working on, and should it pass, it would include all of the functionality of Gina's proposal and then some. --Larry Garfield

Matthew Brown

97 days ago
On Tue, 26 May 2026 at 15:18, Larry Garfield <larry@garfieldtech.com> wrote:
> On Tue, May 26, 2026, at 8:12 AM, Rowan Tommins [IMSoP] wrote: > > On 25 May 2026 20:26:41 BST, Matthew Brown <matthewmatthew@gmail.com> > wrote: > > >> https://thephp.foundation/blog/2025/08/05/compile-generics/ is > >> > >>That blog post is a year old, and the top comment on the attached Reddit > >>thread captures my view on it: > >> > >>> The problem with such an approach is that it locks generics to be > >>reified. > > > > > > I think the two proposals have a lot more overlap than their framing > > suggests. Both block us from having completely erased generics, because > > they include *some* reification/monomorphization, but nothing in > > Gina/Larry's blog post would actually block Seifeddine's proposal as > > far as I can see. > > > > Specifically, if you take everything Seifeddine has implemented, but 1) > > restrict generic declarations to only interfaces and abstract classes; > > and 2) remove the "turbofish" syntax completely; then you end up > > basically with what the blog post suggests. > > > > > > The main difference of opinion is what to do with those "missing" parts: > > > > - Larry & Gina proposed just leaving then forbidden until we decide how > > to implement them > > - Seifeddine proposed locking in the syntax with some partial checking, > > but mostly just reflection support > > I would agree. Seif's proposal is effectively a superset of what Gina was > working on, and should it pass, it would include all of the functionality > of Gina's proposal and then some. > > --Larry Garfield >
Yes it's a superset of the syntax, and leaves the door open to some runtime checks being added in the future, per you & Gina's proposal.

Gina P. Banyard

91 days ago
On Sunday, 10 May 2026 at 21:05, 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: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969
As you already know from off list discussion I will vote against this RFC. I've laid my arguments in a blog post on my website. [1] The only thing I take offence to is saying my attempt/proposal has stalled. Me not actively working on it 24/7 doesn't mean it stalled, and I had started working on it earlier in the year before this RFC landed, or I was aware you were working on something. Best regards, Gina P. Banyard [1] https://gpb.moe/blog/opinion-bound-erased-generics.html

Matthew Brown

89 days ago
On Tue, 2 Jun 2026 at 11:15, Gina P. Banyard <internals@gpb.moe> wrote:
> As you already know from off list discussion I will vote against this RFC. > I've laid my arguments in a blog post on my website. [1] > > The only thing I take offence to is saying my attempt/proposal has stalled. > Me not actively working on it 24/7 doesn't mean it stalled, and I had > started working on it earlier in the year before this RFC landed, or I was > aware you were working on something. > > Best regards, > > Gina P. Banyard > > [1] https://gpb.moe/blog/opinion-bound-erased-generics.html
Thanks for this article! It's a very thorough set of complaints. A few central assertions jumped out to me:
> assuming this survey had a representative sample of PHP users ... and
that nobody uses both PHPStan and Psalm at the same time ... we would get at best 44% of users employing SA tools. A far cry from the 90% number I’m constantly told In that same survey [1], 68% of respondents say they use PhpStorm, and static analysis is one of the *main *reasons people use PhpStorm. That easily gets us to >80% of survey respondents using some sort of static analysis tool in their flow, accounting for people using more than one.
> Indeed, the majority of static analysis tools introduced additional
atomic types that do not exist within PHP — class-string, non-empty-list, positive-int, numeric ... I want to say — as the person who came up with most of those — that some of the docblock types do go a little overboard. But the generic ones are on the money. Best wishes, Matt [1] https://blog.jetbrains.com/phpstorm/2025/10/state-of-php-2025/#most-used-ide-or-editor

Matthew Brown

89 days ago
On Wed, 3 Jun 2026 at 18:49, Matthew Brown <matthewmatthew@gmail.com> wrote:
> > > On Tue, 2 Jun 2026 at 11:15, Gina P. Banyard <internals@gpb.moe> wrote: > >> As you already know from off list discussion I will vote against this RFC. >> I've laid my arguments in a blog post on my website. [1] >> >> The only thing I take offence to is saying my attempt/proposal has >> stalled. >> Me not actively working on it 24/7 doesn't mean it stalled, and I had >> started working on it earlier in the year before this RFC landed, or I was >> aware you were working on something. >> >> Best regards, >> >> Gina P. Banyard >> >> [1] https://gpb.moe/blog/opinion-bound-erased-generics.html > >
Also: the article also dedicates a long section to the potential for transpilers. I believe a transpiler would fail to gain ground for a few reasons: - The parser would need to keep up with the upstream parser, like nikic/php-parser does - But it could not share the same source code as nikic/php-parser — it would need to be a fork, as the code would no longer be PHP - To provide accurate error positions, static analysis tools would need to support the "enhanced" ASTs directly (which would mean they could no longer use nikic/php-parser) - Source maps. Source maps everywhere. And then there's the trust issue which you reference in your article. Trust is *incredibly* important when developers choose to migrate all their source code to a new system. Microsoft's TypeScript has earned that trust over the course of a decade with incredible tooling that has cost that company tens of millions of dollars to produce and market. That effort is not trivial to reproduce, nor would the vast majority of PHP repos likely deem it worthwhile. OTOH if this proposal is accepted everyone those same audiences get generics without any effort on their part. I believe that's why there is strong community support for this proposal.

Rowan Tommins [IMSoP]

86 days ago
On 03/06/2026 23:49, Matthew Brown wrote:
> > assuming this survey had a representative sample of PHP users ... > and that nobody uses both PHPStan and Psalm at the same time ... we > would get at best 44% of users employing SA tools. A far cry from the > 90% number I’m constantly told > > In that same survey [1], 68% of respondents say they use PhpStorm, and > static analysis is one of the /main /reasons people use PhpStorm.
I don't think this is equivalent. PhpStorm only complains if it can prove a type is wrong; and in my experience, there are plenty of people who happily ignore the errors it does flag. To replace the safety of run-time checks, you need a tool that only allows code into production if it can prove that no type errors would happen. That's a different question from "who would a nice syntax for generics be useful for" - I think it would be *useful* even for people coding in a very simple editor, because it makes the code more expressive. But it would be considerably *less useful* if that extra information couldn't actually be trusted because nothing enforced it.
-- Rowan Tommins [IMSoP]

Ondřej Mirtes

89 days ago
On 2. 6. 2026 at 17:13:38, Gina P. Banyard <internals@gpb.moe> wrote:
> As you already know from off list discussion I will vote against this RFC. > I've laid my arguments in a blog post on my website. [1] >
I really enjoyed your article about the generics RFC. Your arguments all make sense and are well built and argued. These arguments are against having bound-erased generic types and they are completely valid. The RFC presents arguments for having bound-erased generic types in the languages and they are also completely valid. It’s good to point out and stress the point that the proposed generics are not completely erased, they are still completely accessible in the reflection (which could be taken advantage of by frameworks and apps to deduplicate some information in the code) and they are bound-erased, which means that some type-checking (which can be done cheaply) will still be performed. I’d also like to point out a few places in currently shipping PHP version where you can put types in native syntax and they are not checked at runtime, meaning completely wrong and non-existent types are silently being accepted or skipped. I didn’t have to think hard about them, I’ve been using these examples for years at the beginning of my presentations about PHPStan and what errors in code it detects in contrast to PHP which doesn’t detect them. - Foo::class - PHP will happily create literal string ‘Foo’ out of this even if class Foo does not exist. - catch (FooException) - PHP will not tell you you’re trying to catch an exception class (or interface) that does not exist (or can’t be a subtype of Throwable), it will silently skip this catch block. - Using a non-existent class as a property/parameter/return type. PHP will not error when loading the function or class with the method in question, but it will error on anything you will try to pass to it. - Using a trait name as a property/parameter/return type. PHP does not report an error on the declaration either, but it will error on anything you will try to pass to it. Including a class that uses the trait. Proof: https://3v4l.org/vH9Bn#v So it’s not completely unprecedented. Sure, these examples might also surprise a lot of developers writing PHP every day, but they’re there. Ondřej Mirtes

Ondřej Mirtes

89 days ago
On 4. 6. 2026 at 12:29:42, Ondřej Mirtes <ondrej@mirtes.cz> wrote:
> ’d also like to point out a few places in currently shipping PHP version > where you can put types in native syntax and they are not checked at > runtime, meaning completely wrong and non-existent types are silently being > accepted or skipped. >
I just remembered one more place: attributes. They’re not validated until something tries to newInstance them via reflection. Which might be never. They can be invalid for all kinds of reasons - nonexistent class, class that isn’t an attribute, targeting wrong target. Proof: https://3v4l.org/Pmai6#v Ondřej Mirtes

Rowan Tommins [IMSoP]

89 days ago
On 4 June 2026 11:58:46 BST, "Ondřej Mirtes" <ondrej@mirtes.cz> wrote:
> On 4. 6. 2026 at 12:29:42, Ondřej Mirtes <ondrej@mirtes.cz> wrote: > >> ’d also like to point out a few places in currently shipping PHP version >> where you can put types in native syntax and they are not checked at >> runtime, meaning completely wrong and non-existent types are silently being >> accepted or skipped. >> > >I just remembered one more place: attributes.
Interestingly, all of these examples effectively make the *opposite* check to what Seifeddine is proposing: the *name* is unchecked, but the *value* compared to it is always rejected as not matching. In "function foo(Nonesuch $x) {}", there is no complaint that "Nonesuch" is not a valid class, but the function can't be called because no parameter matches. In the proposed "function foo(Container<Nonesuch> $x)", the bounds checking will require Nonesuch to exist, but the function can be called with any instance of Collection. The function would be callable even if an instance of Nonesuch was impossible to create (an interface with no implementations, or a final private constructor, etc). None of your examples breaks the invariant "if I mark a value as requiring a particular type, I can be sure that reading that value will give me a value of that type". The only marginal case would be parameters to attributes, which can be read through reflection without running the constructor, and therefore without validating the types specified in that constructor. But that's in keeping with the power of reflection to bypass the language's normal invariants, e.g. newInstanceWithoutConstructor, setAccessible, etc So I do think what Seifeddine is proposing is unprecedented in that sense. Rowan Tommins [IMSoP]

Seifeddine Gmati

88 days ago
On Tue, 2 Jun 2026 at 16:13, Gina P. Banyard <internals@gpb.moe> wrote:
> On Sunday, 10 May 2026 at 21:05, 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: https://wiki.php.net/rfc/bound_erased_generic_types > > - Implementation: https://github.com/php/php-src/pull/21969 > > As you already know from off list discussion I will vote against this RFC. > I've laid my arguments in a blog post on my website. [1] > > The only thing I take offence to is saying my attempt/proposal has stalled. > Me not actively working on it 24/7 doesn't mean it stalled, and I had > started working on it earlier in the year before this RFC landed, or I was > aware you were working on something. > > Best regards, > > Gina P. Banyard > > [1] https://gpb.moe/blog/opinion-bound-erased-generics.html
Hi Gina, Thanks for the blog post. Even though we'll disagree on the conclusion, the arguments are well-laid-out and worth engaging with on their merits. Matt and Ondřej have responded to most of the substantive points already, and I'd echo what they said. One small clarification on the "stalled" language: I didn't mean (and didn't intend to imply) anything about the people doing the work, or the effort they were putting in. I'm well aware that everyone working on this is doing it on top of other commitments, and progress at the pace people can sustain is the only realistic pace there is. What I meant by "stalled" was specifically the public RFC process: the 2016 RFC remained in Draft, no implementation reached a vote, and the 2024 work continued but didn't produce a filed RFC. That's a statement about the procedural milestones, not the engineering work or the people doing it. I should have phrased it more precisely. Apologies for the imprecision. I appreciate the work you have been doing on compile-time generics for class-likes, and the RFC's Future Scope section explicitly notes that the two approaches compose. Whatever this RFC's outcome, that work continues to be valuable. Cheers, Seifeddine.

Rob Landers

85 days ago
On Sun, May 10, 2026, at 21:02, 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 > - Implementation: https://github.com/php/php-src/pull/21969 > > Thanks, > Seifeddine. >
Hello internals, For those not in discord, I spent nearly a week attempting to implement reified generics on top of this branch to see how challenging it would be. I have a working implementation: https://github.com/php/php-src/compare/master...bottledcode:php-src:reify Disclosure: AI assisted in tests and memory leak bug hunts. *The Approach* Classes are monomorphized and similar to Gina's substitution approach -- a monomorphized class shares as much memory as the templated class as possible, mainly holding its substitutions. This happens during runtime, only when it cannot be done at compile time (which is mostly already handled by this branch). Functions/methods get bound in call frames and share the substituted types from their parent (class, outer closure, etc). *The Result* Given: class Box<T : object> { public function __construct(public T $value) {} public function get(): T { return $this->value; } } The following work as expected: $b = new Box::<DateTime>(new DateTime()); $b instanceof Box<DateTime>; // true $b instanceof Box<Request>; // false Whereas the following will generate errors at runtime: $b = new Box::<DateTime>(new Response); $b = new Box::<int>('password 123'); *Performance* Importantly, for code that does not use generics: there is no impact (generics specific code is skipped). For a call (either `new` or a function/method call) that does use generics, the call takes **up to** ~2x a non-generic call. By using Seif's PSL library as a test, and converting it to generics, we were able to show that benchmarks go about 1.3-1.5x slower when comparerd to *no type checking *on the original (ie, `mixed`). Of note, when doing manual type checking with `mixed`, the performance cost of generics is roughly the same. That means: function foo<T>(T $val): T { return $val; } function bar(mixed $val): mixed { if (is_int($val)) { return $val; } throw new Exception(); } These two functions will have roughly the same performance, with some jitter depending on the complexity of the type being checked. I think this is important to point out: if checked generics are "about as fast as manual checks", then it behooves us to go for checked generics and not erased generics, which would force everyone to type check manually. Whether that happens in this same RFC or a later one is an open question. *Related Bug* One other issue discovered while working on this branch, the following doesn't behave as written with erased generics: try { do_http_call(); } catch(HttpError<NotFound> $e) { // ignore } catch(HttpError<Forbidden> $e) { // alert: api key has been revoked } With erased generics, the latter is never hit as it is erased to just `HttpError` ... no warning, no error ... it doesn't work as written. To me, that is a massive footgun. Checked generics work as written. *Limited Inference* Secondly, I was able to get limited inference "for free" with this approach. This works: class Foo { public string $kind = 'foo'; } class Bar { public string $kind = 'bar'; } function kind<T : object>(T $x): string { return T::class; } echo kind(new Foo) . "\n"; echo kind(new Bar) . "\n"; // outputs: // Foo // Bar I would appreciate it if others could review the code to verify my results and implementation. Assuming it is soound, I believe this should resolve issues people have been raising with partially erased generics and give us sufficiently complete generics. Whether to write a new RFC or merge into a single RFC is still being discussed in discord and open for discussion here. PS. My normal email address is broken, so this is a new email address on this list. 👋 — Rob

Brent Roose

84 days ago
Hi Rob Very interesting. If this is what it takes to get enough people on board, then so be it. On a personal note, but don't let this prevent you from further exploring this option: I hope that at some point we get a way to opt out of runtime type checks (generics or types in general). A 2x performance penalty is a serious issue. To reiterate what I've said before: generics aren't a runtime tool. Their value comes from static analysis and reflection for meta programming, and I see no reason why generic code should be type-checked at runtime when it has already been type-checked before. As both a PHP user and a representative for one of PHP's most used static analysers, I want three things: a proper spec, proper syntax, and proper reflection. All these problems are solved with the current RFC; without runtime type checking, and without performance penalties. Maybe this is just a necessary process for PHP to go through; and who knows, in a couple of years, practical experience will have shown and convinced enough people that it's unnecessary. I'll happily deal with the performance overhead and will continue to hope for an opt-out mechanism in the future. Thanks for the work and effort, really interesting! Brent On Mon, Jun 8, 2026 at 5:17 PM Rob Landers <rob@getswytch.com> wrote:

Rob Landers

84 days ago
On Tue, Jun 9, 2026, at 09:26, Brent Roose wrote:
> Hi Rob > > Very interesting. If this is what it takes to get enough people on board, then so be it. > > On a personal note, but don't let this prevent you from further exploring this option: I hope that at some point we get a way to opt out of runtime type checks (generics or types in general). A 2x performance penalty is a serious issue. > > To reiterate what I've said before: generics aren't a runtime tool. Their value comes from static analysis and reflection for meta programming, and I see no reason why generic code should be type-checked at runtime when it has already been type-checked before. As both a PHP user and a representative for one of PHP's most used static analysers, I want three things: a proper spec, proper syntax, and proper reflection. All these problems are solved with the current RFC; without runtime type checking, and without performance penalties. > > Maybe this is just a necessary process for PHP to go through; and who knows, in a couple of years, practical experience will have shown and convinced enough people that it's unnecessary. I'll happily deal with the performance overhead and will continue to hope for an opt-out mechanism in the future. > > Thanks for the work and effort, really interesting! > > Brent
Please remember to "bottom post" (put your reply at the bottom, not the top, of the email).
> A 2x performance penalty is a serious issue.
This is a mischaracterization. To be more clear, the *call itself* approaches a limit of 2x -- it isn't exactly 2x, and not all generic calls are always 2x. I even gave real examples in my email showing real world generic heavy code is only 30-50% slower. And this is without any real optimization work. This is the ceiling, not a final result. Further, it appears to bear the *same *cost as manually type checking the generics.
> I see no reason why generic code should be type-checked at runtime when it has already been type-checked before
PHP does type checks at runtime because *it cannot be type-checked before.* There is no point where the compiler can be sure about types statically. Each file is compiled at a time, and some parts of compilation even need to be deferred to runtime. This isn't a limitation of PHP, it's how you can have two files with the same class name in them and then load different ones depending on capabilities/execution. (ie, loading generated mocks instead of the real class or loading a php 8.3 version instead of a php 9 version of the class). I know of absolutely zero static analysis tools that understand PHP's dynamic loading system -- they all assume a PSR-4 (or similar) system for autoloading. Thus, only in certain kinds of cases are they actually useful.
> All these problems are solved with the current RFC; without runtime type checking, and without performance penalties.
I disagree, wholeheartedly. But that is likely because during my work, I had to actually fix tests from the erasure branch that "looked" right but were quite wrong. As I mentioned in my email, the cost turns out to be nearly exactly the same as manual type checks. The performance issue is a mirage if you want to do any type assertions -- and you probably do. As I mentioned in my last email, you can simply write a script today to remove all types in your code. There's nothing stopping you from doing that to get some "speed". — Rob

Larry Garfield

84 days ago
On Tue, Jun 9, 2026, at 10:30 AM, Rob Landers wrote:
>> A 2x performance penalty is a serious issue. > > This is a mischaracterization. To be more clear, the *call itself* > approaches a limit of 2x -- it isn't exactly 2x, and not all generic > calls are always 2x. I even gave real examples in my email showing real > world generic heavy code is only 30-50% slower. And this is without any > real optimization work. This is the ceiling, not a final result. > Further, it appears to bear the *same *cost as manually type checking > the generics.
Also, as noted, the cost is born only when a generic type is checked. The impact on non-generic code is, seemingly, zero. That is important.
>> I see no reason why generic code should be type-checked at runtime when it has already been type-checked before > > PHP does type checks at runtime because *it cannot be type-checked > before.* There is no point where the compiler can be sure about types > statically. Each file is compiled at a time, and some parts of > compilation even need to be deferred to runtime. This isn't a > limitation of PHP, it's how you can have two files with the same class > name in them and then load different ones depending on > capabilities/execution. (ie, loading generated mocks instead of the > real class or loading a php 8.3 version instead of a php 9 version of > the class). > > I know of absolutely zero static analysis tools that understand PHP's > dynamic loading system -- they all assume a PSR-4 (or similar) system > for autoloading. Thus, only in certain kinds of cases are they actually > useful. > >> All these problems are solved with the current RFC; without runtime type checking, and without performance penalties. > > I disagree, wholeheartedly. But that is likely because during my work, > I had to actually fix tests from the erasure branch that "looked" right > but were quite wrong. As I mentioned in my email, the cost turns out to > be nearly exactly the same as manual type checks. The performance issue > is a mirage if you want to do any type assertions -- and you probably > do. As I mentioned in my last email, you can simply write a script > today to remove all types in your code. There's nothing stopping you > from doing that to get some "speed". > > — Rob
I will go a step further: There are certain things that PHP *cannot* do nicely in core/stdlib without generics. Gina's Fetch API proposal is one such case. (Replacing ArrayAccess with a series of smaller interfaces that are more targeted and well-designed.) All of the parameters of those methods would need to be typed `mixed` today, and implementations could not narrow it. If those interfaces are generic, that problem disappears. Collections is another. I want proper collection objects, badly. :-) That includes operator overloads, which means they must be done in C/stdlib (and even if we had user-space operator overloads, it would be faster in C rather than building them all atop arrays). That means they cannot rely on 4 slightly different user-space static analyzers for pseudo-generics. They need real generics, in the language, or they'll be largely useless. And no, trying to include stubs for auto-complete that match those 4 slightly different user-space static analyzers is not a solution. SA-only types are a hack, not a core language feature. That's what I meant with my earlier comments about a first-party SA tool. PHP the language needs to be an "internally complete" solution. Seif's RFC is a big step forward, with the major issue of introducing type erasure when PHP has never had that before. Rob's add-on seems to resolve that problem. I am not in a position to judge its implementation in detail, but if it passes muster, I think we may have finally found our way forward. And I am willing to eat some performance for that, as long as it only hits generic code. --Larry Garfield

Seifeddine Gmati

84 days ago
Hi Rob,
> This is a mischaracterization. To be more clear, the call itself approaches a limit of 2x -- it isn't exactly 2x, and not all generic calls are always 2x. I even gave real examples in my email showing real world generic heavy code is only 30-50% slower. And this is without any real optimization work. This is the ceiling, not a final result. Further, it appears to bear the same cost as manually type checking the generics.
Real engineering, and the implementation is appreciated. But I disagree that "approaches 2x worst case, 30-50% on real generic-heavy code" reads as a small cost. Two reasons: 1. Generic-via-native isn't "use it OR keep using @template", once it ships, the docblock alternative goes away. Social pressure on every actively-maintained library is to migrate. Static-analysis tools will gradually downgrade or drop (eventually) their docblock-generic implementations once the native form is widely available (this is exactly what happened with attributes vs PHPDoc annotations after PHP 8.0). The cost isn't paid by libraries that opt into runtime-reified generics; rather, it's paid by every library that ships generics at all. Under either proposal, this is essentially every typed library that exposes a collection, iterable, repository, result type, or async primitive. 2. The cost compounds through the dependency graph, affecting even users who don't write generics themselves. Consider an application that never declares a generic of its own. It depends on Psl. Psl is generic end-to-end, covering collections, iterables, results, and options. Application code calling `Psl\Vec\map($vec, $fn)` pays the reified-check cost on every call, transitively, even though the application never wrote <...>. Now a concrete tooling case: [roave/BackwardCompatibilityCheck](https://github.com/Roave/BackwardCompatibilityCheck) runs as a CI step. It uses its own generics. It depends on Psl. The honest benchmark isn't "how much slower is one Psl\Vec\map call?", it's "how much longer does BackwardCompatibilityCheck take to run on a full codebase once Psl ships native generics and BCC adopts them?"If the answer is 4 minutes -> 6 minutes per CI run, multiplied across every PR in every project that uses BCC, that means slower CI, more compute spend, and longer feedback loops for everyone downstream. The "30-50% on generic-heavy code" framing also collapses once a user can't tell which of their dependencies count as "generic-heavy." Under this RFC, the cost question is "does your code use generics?", zero if no, small if yes (the substituted-contract checks I just landed sit at ±0.1% benchmark noise). Under universal reification, the question becomes "do you, or anything in your transitive dependency graph, declare generic types?", and for an ecosystem where ORM repositories, collection libraries, async runtimes, HTTP middleware kits, and standard-library replacements all want generics, the honest answer is "yes, everywhere." This isn't an argument against reified generics in principle. It's an argument that the perf characterisation in the post understates how the cost will be felt in practice once the syntax is the way to do generics in PHP. I think the right path is to land this RFC (erased + substituted contract + reflection, zero cost for non-generic code, small cost for substituted check sites), let the ecosystem migrate from docblock to native, and then evaluate reified-via-opt-in on top of it. Your branch is then the natural starting point for that follow-up RFC, with much sharper data on what fraction of real PHP workloads actually carry generic types in their hot path. If that follow-up shows reified can land at a perf cost the ecosystem can absorb across the dependency-graph case, great. If it can't, we'll know, and the cost of finding that out will be much lower if the erased form is the baseline rather than the starting point. Cheers, Seifeddine

Rowan Tommins [IMSoP]

84 days ago
On 9 June 2026 08:26:12 BST, Brent Roose <brent.roose@jetbrains.com> wrote:
>To reiterate what I've said before: generics aren't a runtime tool.
Generics are as much a runtime tool - or as little - as any other type declaration. If it is useful to enforce "function foo(int $bar): int" at runtime, it is equally useful to enforce "function foo<T>(T $bar): T" at runtime. A mode which erases all runtime checks which can be proven statically would be a valuable addition. Again, we're into "ship a native analyser" territory, though, because you have to know which types can be trusted, and which are still unchecked. Importantly, you can't rely on static analysis outside of a closed system - a library can't choose to erase all types, and still make assumptions in code based on users not passing other types. PHPStan has a specific setting to account for this:
> PHPStan by default doesn’t differentiate between PHPDoc and native types. It considers them both as certain. > > This might not be what you want in case you’re writing a library whose users might pass a wrong argument type to a function. Setting treatPhpDocTypesAsCertain to false relaxes some of the rules around type-checking.
Replace "PHPDoc" with "erased" and the statement applies to the current discussion. For example: ``` interface Foo<T: int|string> { public function bar(): T; } function test(Foo<int> $foo): void { $bar = $foo->bar(); if ( ! is_int($bar) ) { throw new \TypeError('Expected an int'); } } ``` If the generic type can be trusted, then the is_int() check is redundant and can safely be removed. If generics are checked at runtime, it can be trusted; if the final running system has been successfully analysed and proven correct, it can be trusted. But analysing this code in isolation *can't* make it trusted; and analyzing the final running system may find cases which can't be proven until runtime. As I understand it, the "partial erasure" approach, where the static analyser decides which runtime checks are needed, is used by Dart: https://dart.dev/language/type-system Rowan Tommins [IMSoP]

Ilija Tovilo

84 days ago
Hi Rob On 08.06.26 17:13, Rob Landers wrote:
> On Sun, May 10, 2026, at 21:02, Seifeddine Gmati wrote: >> - RFC: https://wiki.php.net/rfc/bound_erased_generic_types >> - Implementation: https://github.com/php/php-src/pull/21969 > > For those not in discord, I spent nearly a week attempting to > implement reified generics on top of this branch to see how > challenging it would be. > > I have a working implementation: > https://github.com/php/php-src/compare/master...bottledcode:php-src:reify
I think it's worth looking back at Nikita's comment when he researched generics, and I think it holds today. It touches pretty much on everything discussed. https://www.reddit.com/r/PHP/comments/j65968/comment/g83skiz/
> Classes are monomorphized and similar to Gina's substitution approach > -- a monomorphized class shares as much memory as the templated class > as possible, mainly holding its substitutions. This happens during > runtime, only when it cannot be done at compile time (which is mostly > already handled by this branch).
Nikita addresses this here:
> The main problem with monomorphization is not so much performance (it > is theoretically good for performance, and even an otherwise reified > generics implementation may wish to monomorphize hot classes for > performance reasons), and more about memory usage. It requires a > separate class to be generated for each combination of type arguments. > If that also involves duplication all methods (which may depend on > type arguments), this will need a lot of memory. > > Monomorphization as a primary implementation strategy doesn't make a > lot of sense in PHP: It is important for languages like C++ or Rust, > where the ability to specialize code for specific types is highly > performance critical (and even so code size remains a big problem). In > PHP, we will not get enough performance benefit out of it to justify > the memory cost (again, when talking about blanket monomorphization). > Especially as it's not clear how it would be possible to cache > monomorphized methods in opcache (due to immutability requirements). > > The only reason why monomorphization was suggested as an > implementation strategy at all is that it would make the > implementation of a naive generics model simpler: The premise is that > we just need to generate new class entries for everything, and the > rest of the engine doesn't need to know anything about generics. > However, this doesn't hold up once you consider variance for generic > parameters (Traversable<int> is a Traversable<int|string>), as such > relations cannot really be modelled without direct knowledge of the > generic parameters.
With full monomorphized generics, we can discover new variants of a generic class effectively forever. This is especially bad for generic data structures, which is also generics' biggest use-case. In practice, this means higher memory consumption (incl. higher risk of frequent opcache restarts due to filling shared memory) and more time looking up classes, for no benefit. That said, it's hard to argue one way or another without concrete numbers, so that should be the first step to progress the conversation beyond what we had 6 years ago.
> Secondly, I was able to get limited inference "for free" with this > approach.
From my testing on your branch, the type inference is very limited. First of all, Nikita explains why type inference is important to begin with:
> Generics are already hard on a purely conceptual level -- while we > tend to talk about the implementation issues, as these are the > immediate blocker, there's plenty of design aspects that remain > unclear. One part that bothers me in particular is the question of > type inference: > >     function test(): List<int> { >         // We don't want to write this: >         return new List<int>(1, 2, 3); >         // We want to write this: >         return new List(1, 2, 3); >     } > > We certainly wouldn't want people to write out more types in PHP than > they would do in a modern statically typed language like Rust. > However, I don't really see how type inference for generic parameters > could currently be integrated into PHP, primarily due to the very > limited view of the codebase the PHP compiler has (it only sees one > file at a time). The above example is obvious, but nearly anything > beyond that seems to quickly shift into "impossible".
The above example seems to apply to your implementation (while working in Seifeddine's branch):     class Box<T> {         public function __construct(public T $value) {}     }     // Cannot instantiate generic class Box without type arguments; type parameter T has no default     $box = new Box(42);     // Ok     $box = new Box::<int>(42); PHP static analyzers are necessarily very capable at static type inference, so this is at least a significant discrepancy between your implementation and a type erasure approach. This actually also questions the gradual typing narrative for type erasure (i.e. "we can add type checks later"), because it's questionable a reified approach will support the exact set of (correct) programs the erased approach will. That's especially true because PHP does not have an official static analyzer, and the 3rd party analyzers do practically diverge in the details. It seems for functions, T is not required to be inferred unless used, likely to circumvent the above limitation. This can lead to confusing cases.     // Works     function a<T>(T $value) { b($value); }     function b<T>(T $value) {}     a(42);     // Breaks (also crashes with ASan due to access to uninitialized memory)     function a<T>(T $value) { b($value); }     function b<T>(T $value) { new Box::<T>($value); }     a(42);     // Works     function a<T>(T $value) { b::<T>($value); }     function b<T>(T $value) { new Box::<T>($value); }     a::<int>(42); In other words, whether a(42) is safe to call for signature `function a<T>(T $value)` depends on its implementation, i.e. whether it makes use of T. This is introduces a variation of the function coloring problem, where now all callers must specify generic types recursively. The only way to fix this inconsistency (apart from actual type inference) is to require specifying the type explicitly at all times. I ran into quite a few other ASan crashes when testing examples, as with PHPs own test suite. Given I'm not sure how complete the implementation intends to be, I won't go into those any further. Ilija

Seifeddine Gmati

84 days ago
Hi Ilija,
> From my testing on your branch, the type inference is very limited. > [...] > PHP static analyzers are necessarily very capable at static type > inference, so this is at least a significant discrepancy between > your implementation and a type erasure approach.
A reified implementation should require turbofish at every call site rather than try to do engine-level inference. The two paths land in different places here. For an erased model (this RFC), inference belongs in the static-analysis layer. The runtime doesn't need to know what T is. It's been erased to its bound by the time the call happens, so `new Box(42)` produces a Box object with value typed as `mixed` at runtime. PHPStan, Psalm, Phan, or Mago can independently decide whether to infer `Box<int>`, `Box<int|string>`, Box<scalar>, or leave T open at the static layer, and their disagreement doesn't affect runtime semantics. That's why this RFC leaves turbofish optional. Forcing it would be work the engine has no use for. The only callers that need turbofish under erasure are SA tools when they can't figure out user intent, same as TypeScript saying "needs explicit type argument" when nothing in the call site constrains T. For a reified model, inference becomes an engine problem because the runtime actually carries the type argument forward. Now `new Box(42)` has to decide on a concrete T at construction. The clean fix is to require turbofish at every call site that takes a generic-parametric type, and reject the call as a compile or runtime error otherwise. No engine-side inference, ever. The user states intent explicitly. The engine doesn't guess. That sidesteps the question of what `new Box(42)` means: under reification it becomes invalid PHP, and the user writes `new Box::<int>(42)`.
> In other words, whether a(42) is safe to call for signature > `function a<T>(T $value)` depends on its implementation, i.e. > whether it makes use of T. This is introduces a variation of the > function coloring problem, where now all callers must specify > generic types recursively.
Function coloring is a different problem. Coloring (in the async/await sense) means a property of one function forces the same property on every function that calls it. If you call an async function, you have to be async yourself, and so on transitively up the call graph. Mandatory turbofish doesn't propagate that way. Consider: ```php function id<T>(T $v): T { return $v; } function str_id(string $v): string { return id::<string>($v); } ``` `str_id` is not generic. It supplies the type argument at the call site and that's the end of it. The "color" doesn't travel upward. `str_id`'s own signature stays exactly as the author wrote it, with no generic parameters of its own required. Turbofish is a property of the call site. The calling function's own signature is unaffected. The case where you'd add a generic parameter to `str_id` is when `str_id` legitimately needs to be parametric, forwarding `T` through. That's a design choice the author makes. The language doesn't impose it. The issue you found in Rob's branch is real: half-done inference makes call-site safety depend on the callee's implementation. That's a problem with partial inference, not with the call-site syntax. The fix is the same: require turbofish at every call site, no engine-side inference. Once the engine stops guessing, call-site safety stops depending on what the body of the callee does. TypeScript-style inference for T belongs in the SA layer, where it can be tool-specific and revisable. The engine should not be in that business. Cheers, Seifeddine

Rob Landers

84 days ago
To reply to both of you:
> The issue you found in Rob's branch is real: half-done inference makes > call-site safety depend on the callee's implementation. That's a > problem with partial inference, not with the call-site syntax. The fix > is the same: require turbofish at every call site, no engine-side > inference. Once the engine stops guessing, call-site safety stops > depending on what the body of the callee does.
I don't have enough time today to write out long responses to both your comments. But to this end, scalar inference requires deciding how to handle at the RFC level, not a POC level. new Box(42); new Box(42.0); new Box("42") In non-strict mode, are these all equivalent? What exactly is T's type here? Scalars in PHP ... are weird. I wasn't going to try to define it here. — Rob

Matthew Brown

82 days ago
On Tue, 9 Jun 2026 at 10:23, Rob Landers <rob@getswytch.com> wrote:
> I don't have enough time today to write out long responses to both your > comments. But to this end, scalar inference requires deciding how to handle > at the RFC level, not a POC level. > > new Box(42); > new Box(42.0); > new Box("42") > > > In non-strict mode, are these all equivalent? What exactly is T's type > here? > > Scalars in PHP ... are weird. I wasn't going to try to define it here. > > — Rob >
Handling of this really does not have to be defined at the RFC level. It's a typechecker implementation — and it's ok for them to differ. This is a good post describing how the direct equivalent in Python is treated differently by Python type checkers: https://pyrefly.org/blog/container-inference-comparison/. Best wishes, Matt

Seifeddine Gmati

82 days ago
On Sun, 10 May 2026 at 20:02, 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: https://wiki.php.net/rfc/bound_erased_generic_types > - Implementation: https://github.com/php/php-src/pull/21969 > > Thanks, > Seifeddine.
Hello internals, This is the Intent to Vote message for the Bound-Erased Generic Types RFC. The discussion thread opened on May 10. The RFC text has been stable for the last 14 days, no further Major or Minor changes are pending, and the recent activity in the thread has been around adjacent topics rather than feedback requiring changes to the RFC itself. I plan to open voting on 2026-06-14. If any new substantive feedback comes in during that window, I'll treat it the same as feedback raised earlier in the thread, and any Major or Minor change to the RFC text will reset the cooldown and cancel this Intent to Vote, per policy. The vote will run for 14 days and will be announced in a separate [VOTE] thread once it opens. There will be two questions on the RFC: 1. Primary, 2/3 majority required: accept the RFC as a whole. 2. Secondary, simple majority, conditional on the primary passing: variance marker syntax, `+T` / `-T` (Hack, Scala) vs `in T` / `out T` (C#, Kotlin). Semantically identical; only the surface syntax differs. RFC: https://wiki.php.net/rfc/bound_erased_generic_types Implementation: https://github.com/php/php-src/pull/21969 Thanks to everyone who engaged with this over the past month. Regards, Seifeddine.

Larry Garfield

79 days ago
On Thu, Jun 11, 2026, at 12:27 PM, Seifeddine Gmati wrote:
> Hello internals, > > This is the Intent to Vote message for the Bound-Erased Generic Types RFC. > > The discussion thread opened on May 10. The RFC text has been stable > for the last 14 days, no further Major or Minor changes are pending, > and the recent activity in the thread has been around adjacent topics > rather than feedback requiring changes to the RFC itself. I plan to > open voting on 2026-06-14. > > If any new substantive feedback comes in during that window, I'll > treat it the same as feedback raised earlier in the thread, and any > Major or Minor change to the RFC text will reset the cooldown and > cancel this Intent to Vote, per policy. > > The vote will run for 14 days and will be announced in a separate > [VOTE] thread once it opens. There will be two questions on the RFC: > > 1. Primary, 2/3 majority required: accept the RFC as a whole. > 2. Secondary, simple majority, conditional on the primary passing: > variance marker syntax, `+T` / `-T` (Hack, Scala) vs `in T` / `out T` > (C#, Kotlin). Semantically identical; only the surface syntax differs. > > RFC: https://wiki.php.net/rfc/bound_erased_generic_types > Implementation: https://github.com/php/php-src/pull/21969 > > Thanks to everyone who engaged with this over the past month. > > Regards, > Seifeddine.
I know we've discussed this in chat, but I want to put it out to the list. i strongly urge you to hold off on a vote. Generics would be the biggest PHP feature in *years*, and would generate a lot of very positive buzz, both within the community and externally. OTOH, generics getting voted down (for whatever reason) would be... very bad press, both within the community and externally. I don't feel like Rob's additions have been adequately investigated and evaluated, nor is there a clear consensus on what would be "good enough" for it to be acceptable. That is a problem that should be addressed directly. Additionally, Rob has noted that the current RFC does allow for code that would be broken and change behavior should generics become enforced in the future. That is a landmine we do not want. (And I reiterate, "if you care, use an SA tool" is an insufficient answer.) We basically have three options: 1. Erased generics, with all the limitations and problems that implies (basically Seif's current RFC). 2. Monomorphized generics, with the performance hit that implies (basically Rob's patch, with some further development.) 3. Pass on generics, again. In my mind, option 3 is the absolute worst option. I can think of at least three core features that would benefit from or require generics to be done properly, so having *something* in core is, in my mind, critical. Plus, as previously noted, "Internals rejects generics, even though basically everyone wants it" is going to be the headline (no matter how (in)accurate you feel that is). The only people that win in that scenario are Node.js fanbois. However, my read of the current discussion is that partially-erased generics (this RFC) is not going to pass. I've been struggling with it for a while, and still not sure how I'm going to vote. I really want to support it, but the gaps that Rob pointed out (around catch) are a problem. And again, they're worse than just "oh it's the wrong type so you get a type error." It completely changes the error pathway that gets executed. If partially-erased generics doesn't pass, that will leave us with monomorphized/reified generics. That always runs into the "but performance" problem, yet, there has never been a consensus as to what an acceptable CPU or memory hit would be. Because *there is guaranteed to be one*. If we want generics, then we either accept partial erasure or we accept some performance hit. We're going to have to deal with one or the other, and pretending that some magic free-reified implementation will appear is a fool's errand. So to anyone who plans to vote against the current RFC, please state what you would consider an acceptable performance hit for going all the way. *We need to agree on that*, so that Rob or anyone else can see if we can hit it. That's the step that hasn't been achieved yet (due in part to our current process having no way to handle that). What Rob has expressed so far frankly seems like an acceptable cost to me already, but I know others disagree. But holding out for zero-cost is simply impractical. My recommendation, in fact, would be to get one or more additional people involved to help on the performance front, and put forward an equivalent enforced-generics RFC. (Same syntax and semantics as the current one, which are pretty solid aside from the in/out question.) Let's bang on that and get it right, and then we can pass that. Given the timing, I would suggest such an RFC not target 8.6. Instead, we can plan ahead that 8.6 (this year) will be the end of the 8.x series, PHP 2027 will be PHP 9.0, and will include whatever the result of that further generics work is. That gives us plenty of time to: 1. Ensure it's rock solid, and as performant as we can manage 2. Develop additional features for core that can leverage generics, so when they ship we have stuff already using it. This also helps flush out any edge case bugs before it ships. 3. Gives us a clear path toward a major PR and press win (PHP 9.0, Now with Generics!), which the project desperately needs. I want to reiterate: "Internals votes down generics" is the absolute worst outcome, for literally everyone who cares about PHP. As we say in Chicago, "you don't call the vote until you know you have the votes." Right now, I don't think we have the votes. We need to take the time and do the work to get this *right*, and ensure not just passage, but broad support and endorsement, and the right tooling built on top of it. Voting on the current RFC right now will not get us there. --Larry Garfield

Ben Ramsey

79 days ago
On 6/13/26 14:38, Larry Garfield wrote:
> I want to reiterate: "Internals votes down generics" is the absolute worst outcome, for literally everyone who cares about PHP. As we say in Chicago, "you don't call the vote until you know you have the votes." Right now, I don't think we have the votes. We need to take the time and do the work to get this *right*, and ensure not just passage, but broad support and endorsement, and the right tooling built on top of it. > > Voting on the current RFC right now will not get us there.
I'd like to second Larry's call for holding off on a vote and getting this right and ready for a PHP 9 release in 2027. Cheers, Ben

A.L.E.C

79 days ago
On 14.06.2026 05:01, Ben Ramsey wrote:
>> Voting on the current RFC right now will not get us there. > > I'd like to second Larry's call for holding off on a vote and getting > this right and ready for a PHP 9 release in 2027.
As someone who's gonna vote No on this RFC I concur. Larry's plan makes a lot of sense.
-- Aleksander Machniak Kolab Groupware Developer [https://kolab.org] Roundcube Webmail Developer [https://roundcube.net] ---------------------------------------------------- PGP: 19359DC1 # Blog: https://kolabian.wordpress.com

Arvids Godjuks

79 days ago
On Sat, 13 Jun 2026 at 22:39, Larry Garfield <larry@garfieldtech.com> wrote:
> > I know we've discussed this in chat, but I want to put it out to the list. > > i strongly urge you to hold off on a vote. Generics would be the biggest > PHP feature in *years*, and would generate a lot of very positive buzz, > both within the community and externally. OTOH, generics getting voted > down (for whatever reason) would be... very bad press, both within the > community and externally. > > I don't feel like Rob's additions have been adequately investigated and > evaluated, nor is there a clear consensus on what would be "good enough" > for it to be acceptable. That is a problem that should be addressed > directly. > > Additionally, Rob has noted that the current RFC does allow for code that > would be broken and change behavior should generics become enforced in the > future. That is a landmine we do not want. (And I reiterate, "if you > care, use an SA tool" is an insufficient answer.) > > We basically have three options: > > 1. Erased generics, with all the limitations and problems that implies > (basically Seif's current RFC). > 2. Monomorphized generics, with the performance hit that implies > (basically Rob's patch, with some further development.) > 3. Pass on generics, again. > > In my mind, option 3 is the absolute worst option. I can think of at > least three core features that would benefit from or require generics to be > done properly, so having *something* in core is, in my mind, critical. > Plus, as previously noted, "Internals rejects generics, even though > basically everyone wants it" is going to be the headline (no matter how > (in)accurate you feel that is). The only people that win in that scenario > are Node.js fanbois. > > However, my read of the current discussion is that partially-erased > generics (this RFC) is not going to pass. I've been struggling with it for > a while, and still not sure how I'm going to vote. I really want to > support it, but the gaps that Rob pointed out (around catch) are a > problem. And again, they're worse than just "oh it's the wrong type so you > get a type error." It completely changes the error pathway that gets > executed. > > If partially-erased generics doesn't pass, that will leave us with > monomorphized/reified generics. That always runs into the "but > performance" problem, yet, there has never been a consensus as to what an > acceptable CPU or memory hit would be. Because *there is guaranteed to be > one*. If we want generics, then we either accept partial erasure or we > accept some performance hit. We're going to have to deal with one or the > other, and pretending that some magic free-reified implementation will > appear is a fool's errand. > > So to anyone who plans to vote against the current RFC, please state what > you would consider an acceptable performance hit for going all the way. > *We need to agree on that*, so that Rob or anyone else can see if we can > hit it. That's the step that hasn't been achieved yet (due in part to our > current process having no way to handle that). What Rob has expressed so > far frankly seems like an acceptable cost to me already, but I know others > disagree. But holding out for zero-cost is simply impractical. > > My recommendation, in fact, would be to get one or more additional people > involved to help on the performance front, and put forward an equivalent > enforced-generics RFC. (Same syntax and semantics as the current one, > which are pretty solid aside from the in/out question.) Let's bang on that > and get it right, and then we can pass that. Given the timing, I would > suggest such an RFC not target 8.6. Instead, we can plan ahead that 8.6 > (this year) will be the end of the 8.x series, PHP 2027 will be PHP 9.0, > and will include whatever the result of that further generics work is. > > That gives us plenty of time to: > 1. Ensure it's rock solid, and as performant as we can manage > 2. Develop additional features for core that can leverage generics, so > when they ship we have stuff already using it. This also helps flush out > any edge case bugs before it ships. > 3. Gives us a clear path toward a major PR and press win (PHP 9.0, Now > with Generics!), which the project desperately needs. > > I want to reiterate: "Internals votes down generics" is the absolute worst > outcome, for literally everyone who cares about PHP. As we say in Chicago, > "you don't call the vote until you know you have the votes." Right now, I > don't think we have the votes. We need to take the time and do the work to > get this *right*, and ensure not just passage, but broad support and > endorsement, and the right tooling built on top of it. > > Voting on the current RFC right now will not get us there. > > --Larry Garfield >
Hello everyone, as someone purely on the userland side of things, I agree with Larry's plan. It's sensible and gives plenty of time to actually work out the kinks. As to the Erased Generics vs Monomorphized Generics and their performance hit, I honestly do not see why people are hung up on wanting no performance hit. As a userland developer, I expect generics to have a performance hit, and not a minor one. The more powerful the feature, the greater the performance impact. I also expect generics in PHP to do actual runtime type checking the same way the whole type system in PHP already does (I'm a strict type mode user through and through). If generics end up giving a performance hit of 30-40% in the places they are used compared to non-generics code - frankly, considering the benefits of code simplification, maintainability improvements, removing code we don't need any more and getting a major type safety at runtime - a server or two that might needed to be added is a small price to pay. We are not talking about overall PHP slowing down 30-40% just because a single generic collection or two are used in the code, right? As i understand, the slowdown is at the boundaries of the generic usage, not across all code. And it has been already said, that there are definite optimizations to be had - the effort just has to be put into it. Using try/catch incurs a performance hit, which everyone knows, yet we still use it. Because it is worth it. My 0.02$.
-- Arvīds Godjuks +371 26 851 664 arvids.godjuks@gmail.com Telegram: @psihius https://t.me/psihius

Seifeddine Gmati

79 days ago
Voting is now open. The [VOTE] thread is here: https://news-web.php.net/php.internals/131236 The vote started on 2026-06-14 at 16:50 UTC and ends on 2026-06-28 at 17:00 UTC. Two votes on the RFC page: 1. Primary, 2/3 majority: accept the RFC. 2. Secondary, simple majority, conditional on the primary: variance marker syntax. Thanks again to everyone who contributed to the discussion. Regards, Seifeddine. On Sun, 10 May 2026 at 20:02, Seifeddine Gmati <azjezz@carthage.software> wrote: