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.
Hey Seifeddine,
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
Hi Bob,
Hey Seifeddine,
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.
- 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.
- 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.
- 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.
- 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.).
- 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.
-
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
- 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.
-
- / - 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.
- Runtime generics
Agreed entirely. The design is bound-erased for now. Nothing in it
precludes a follow-up RFC adding reified generics at the inheritance
level. That slice is the most useful and the cleanest to bolt on, the
engine's type-parameter representation is already structured to
support it.
Cheers,
Seifeddine.
Thanks for the quick reply!
Let me respond inline to avoid backtracking too much.
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,
BobThanks for the careful read. Going point by point.
- 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.
- Type parameters in intersection types
The check rejects an intersection where one side is a type parameter
whose bound ismixed, 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. IfTis bound to an object-shaped type (T: object,T: SomeInterface,T: SomeClass, ...), thenT & Foois 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.
- 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 checkedSo 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 fornewand 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!
- 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#vTo 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.
- 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): voidand
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(): intandget(): stringcould in principle be reconciled toget(): 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.
class ABox implements Box<self>It is allowed and works as you'd expect.
selfresolves 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!
- 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.
- / - markers
Picked because they don't require any new reserved words.
in/out
reads well but I'm not comfortable burning two keywords for a feature
where two pieces of punctuation already do the job.On the "+ = sum of accepted" intuition: the convention here is the
standard one from variance literature.+marks positions where the
type can be widened (covariant, e.g., returns),-marks positions
where it can be narrowed (contravariant, e.g., parameters). It also
matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
already maps to.
I understand, I've never used any of those languages for more than
targeted edits, so I didn't know.
I guess it's fine to not diverge here.
By the way, you don't necessarily need a new keyword, in fact you could
just allow two consecutive T_STRING at that position and emit a parser
error when the first one is neither of "in" or "out".
Thanks,
Bob
Hi Bob,
Thanks for the quick reply!
Let me respond inline to avoid backtracking too much.
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,
BobThanks for the careful read. Going point by point.
- 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.
- Type parameters in intersection types
The check rejects an intersection where one side is a type parameter
whose bound ismixed, 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. IfTis bound to an object-shaped type (T: object,T: SomeInterface,T: SomeClass, ...), thenT & Foois 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.
- 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 checkedSo 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 fornewand 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!
- 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#vTo 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.
- 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): voidand
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(): intandget(): stringcould in principle be reconciled toget(): 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.
class ABox implements Box<self>It is allowed and works as you'd expect.
selfresolves 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); // trueNice!
- 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.
- / - 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_STRINGat that position and emit a parser error when the first one is neither of "in" or "out".Thanks,
Bob
Thanks for the reply!
- 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.
- 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!
- 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.
- 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)
- 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.
- 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.
- 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.
- 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.
- 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.
- 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,
Thanks for the quick reply!
Let me respond inline to avoid backtracking too much.
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,
BobThanks for the careful read. Going point by point.
- 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.
- Type parameters in intersection types
The check rejects an intersection where one side is a type parameter
whose bound ismixed, 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. IfTis bound to an object-shaped type (T: object,T: SomeInterface,T: SomeClass, ...), thenT & Foois 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.
- 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 checkedSo 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 fornewand 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!
- 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#vTo 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.
- 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): voidand
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(): intandget(): stringcould in principle be reconciled toget(): 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.
class ABox implements Box<self>It is allowed and works as you'd expect.
selfresolves 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); // trueNice!
- 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.
- / - 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_STRINGat that position and emit a parser error when the first one is neither of "in" or "out".Thanks,
BobThanks for the reply!
- 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>> {} // mutualDefaults 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.
- 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!
- 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>> {} // mutualDefaults 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.
- 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)
- 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.
- 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
Chas 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.
- 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(): intandget(): 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 & Bis 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.
- 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.
- 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
Chas 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.
- 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(): intandget(): 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 & Bis 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.
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
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.
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.
- 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. - 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. - Regarding adding new syntax that doesn't really do anything, there is
precedent with attributes. Similar to generics, annotations were a
docblock-only feature that got dedicated syntax, without any runtime effect
besides reflection. That's exactly the mindset this RFC embraces, and
attributes were very well received by the PHP community. I understand some
people may fear runtime-ignored generics causing confusion, but I don't
think this will be a problem, given that the target audience is: one, used
to this workflow already; and, two, attributes already introduced this
concept of "syntax that has no runtime effect".
I hope that, along with all the arguments Seifeddine made in the RFC, you
seriously consider voting yes on the final RFC, even if you yourself aren't
the target audience for this feature. The vast majority of people who would
benefit from generics are the people already using static analysis.
Speaking with them for years both online and offline, I know most are on
board with this approach. (I say "most of them", but truthfully, everyone I
spoke with over the years is on board. I just avoid saying "all" because
I'm sure someone somewhere disagrees).
Have a good day!
Brent
On Sun, May 10, 2026 at 9:04 PM Seifeddine Gmati azjezz@carthage.software
wrote:
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.
- RFC: https://wiki.php.net/rfc/bound_erased_generic_types
- Implementation: https://github.com/php/php-src/pull/21969
Thanks,
Seifeddine.
Thank you, Seifeddine, for all this work and effort; it was a very clear
and even pleasant read. To internals: I'd like to highlight a couple of
additional reasons why I think you should vote yes on this final RFC.
- 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?
- 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)?
- 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_DEPRECATEDerror 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 year1. I'm not sure what state it's currently in.
Cheers,
Ben
- 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
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.
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.
Hi Seifeddine,
Thanks for putting together this proposal.
However, I am generally against any unenforced implementation of generics. I choose that word carefully: it is not type erasure per se that I am against, but the ability to write code which looks like it enforces particular signatures, but actually does not.
There is lots of background in the RFC, but one relevant comparison not referenced is Python, where all type hints are unenforced. Static analysis has to be run manually, so code can be written, and even published as a library, with completely incorrect type information. Users of that code then make reasonable assumptions based on the published types, and get unexpected run-time behaviour.
Note that this is fundamentally different from the experience of languages like Java or TypeScript, where type information may be erased, but is still enforced, because static analysis is a compulsory part of the compilation process.
Having most types enforced is obviously better than none, but as far as I can see the same risk exists - someone can write code that claims to use generics, and publish it to Packagist without actually checking it; or write supposed invariants which only hold if the user also analyses their own code. This is true today, of course, but the claims are much weaker, sitting in docblocks not native syntax.
Once we go down this route, it will be very hard to change our minds, because enforcing types in code which previously ignored them would be a breaking change.
I think the only options I would personally support are:
a) run-time enforced generics in limited positions (as in the blog post you link to from Gina and Larry)
b) generics which are erased at compile-time by a tool that also enforces them (as in Java, TypeScript, etc)
Regards,
Rowan Tommins
[IMSoP]
Hi Rowan,
I think the core premise is empirically testable, and the test has
already run: PHP has had generics in docblocks for a decade, used by
every major framework. If "someone publishes a library with broken
type information and downstream users get surprised by runtime
behavior" were a real failure mode, we would see it today, as Laravel,
Doctrine, Symfony, PSL, and PHPUnit all run on @template-annotated
code and are consumed by millions of downstream applications. The
failure mode you describe is the one that would occur most under the
current system, yet it doesn't.
The reason it doesn't is that the people who care about generic type
information are the same people who run static analysis. The
intersection of "uses generics" and "doesn't run a static analyzer" is
approximately empty in practice. Native syntax doesn't change that. It
just gives current users a better way to express what they mean.
Regarding the second point, the proposed implementation is enforced
more at compile time than docblocks are. The engine validates:
- Generic parameter declarations: arity cap, bound conformance,
default-vs-bound, no top-level self-reference, no shadowing. - Variance soundness: Covariant parameters cannot appear in input
positions, contravariant parameters cannot appear in output positions,
at the declaration site. - Inheritance: arity at extends/implements/use, bound conformance with
bound-on-bound for forwarded parameters, diamond detection, parametric
LSP into properties/hooks/trait methods/inherited methods. - Turbofish at runtime: arity and bounds at every call site that
supplies type arguments.
Docblocks validate none of this; the engine accepts a docblock that
says anything. Native syntax is a stricter contract than what the
ecosystem has today.
Finally, on the "broken code published to Packagist" concern: someone
can already publish:
/** Map the given array using the provided callable. */
function map(array $array, callable $fn): array {
return [];
}
The signature claims to take an array and a callable, returns an
array. The body returns an empty array regardless. Today, this passes
every PHP type check, parses without error, runs in production, and is
technically conformant. Nobody is calling for PHP to ship a precious
type system 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> & ![]). 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.
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]
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 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> & ![]). 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 Collectionwould still work, but there's noinstanceof 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
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:
-
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. -
A tool competitive with existing ones. This is probably a year or
two long C project requiring a dedicated team, on top of the PHP core
team's existing scope. As someone who built a full toolchain in Rust
from scratsh following Psalm/Hakana's footsteps, I can tell you this
is substantially harder than it looks, and it would be considerably
harder in our case than it was for me, for two reasons:2.1. Mago is written in Rust, which is a more productive language
for this kind of tool and has the ecosystem to match. Building the
equivalent in C, against PHP's existing engine constraints, means a
much higher cost per feature, per bug fix, per refactor.2.2. Mago isn't part of PHP. If we get variance wrong, we ship a
fix the same day. sometimes twice a day, sometimes once a week. We
aren't bound by the php-src release cycle, and we iterate as soon as
we have a fix. A first-party SA tool inherits PHP's release cadence:
fixed-cadence releases, feature freeze windows, stability guarantees.
For a tool whose value depends on keeping up with framework patterns,
library conventions, and emerging idioms, that release shape is wrong.
Beyond the engineering cost, there's a parser problem: PHP's current
parser doesn't produce the full CST that an SA tool needs. You'd need
a new parser, a static name resolver, a static reflection system that
doesn't execute files to extract type information, and so on, a
substantial reimplementation of analysis infrastructure in C, which
the community already has high-quality versions of in Rust and PHP.
In short: shipping a first-party SA tool would be a large, probably
multi-year, investment for a result that's almost certainly worse than
the tools the community has already built. I don't think it's a good
use of PHP's resources, and I'd argue strongly against it.
So for me to vote in favor of this RFC, we would need to have some degree of first-party enforcement
I think the RFC already provides substantial first-party enforcement,
more than the "erased" framing suggests. The engine validates arity at
declaration and inheritance sites, bound conformance including
bound-on-bound for forwarded parameters, variance soundness at
declaration positions, parametric LSP into substituted method
signatures, default-vs-bound conformance, the 127-arg cap, top-level
self-reference and shadowing, and arity+bounds at every turbofish call
site. The compile-time and link-time enforcement surface is comparable
to Java's. What's not enforced is parametricity at call sites, which
is the "erased" part. If the concern is "PHP should validate generic
code itself, not require external tools," then this RFC does that, for
everything the runtime can check without reified types.
I agree with Bob that the +/- syntax is very confusing. I would much rather use in/out, as seen in Kotlin and C#, which are both vastly more self-documenting and match what some of our peer languages are doing.
No strong preference here. I went with +/- because that's what
HackLang uses and it's familiar to me, but in/out is fine if
that's the consensus. Happy to switch if enough people prefer the
keyword form.
An interesting quirk I found in my previous research is that languages that use : for inheritance use : for bounding generics. Languages that use "extends" for inheritance also use "extends" for bounding generics. PHP uses "extends", so that pattern would suggest we use "class Box<T extends FooBar>" rather than :.
I'd disagree on this one. class A<T extends C> extends B reads
awkwardly to me, especially when the bound is scalar or a union eg.,
class A<T extends int|string> extends B, is rough to parse visually.
The colon form keeps the type-parameter bound visually distinct from
the class's own extension relationship, which I think is the more
important consistency to optimize for.
I am completely OK with skipping typed arrays at this point. In practice I'd rather build objects with nice operator overrides directly into the stdlib.
Typed array deserve their own RFC. My initial draft did include
array<K, V>, but without enforcing arity or bounds it's effectively
useless, the array-key coercion problem makes this:
function x(array<string, string> $a, string $k, string $v):
array<string, string> {
$a[$k] = $v;
return $a;
}
potentially return an array<string|int, string> if $k is a numeric
string. Properly typed arrays need separate, more careful design than
this RFC can give them.
I fully expect "turbofish" to result in all kinds of slide shenanigans at conferences. At least on my slides. :-)
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 Collectionwould still work, but there's noinstanceof 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.
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]
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
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.
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@templateannotations 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:
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.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.
-
Nothing says we can't provide a first-party SA tool that's written in Rust instead of C.
-
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, butin/outis 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 Breads
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 Collectionwould still work, but there's noinstanceof Collection<int>alternative. Am I reading that correctly?yes. that's exactly the erasure behavior. the type argument isn't
available at runtime, soinstanceof Collection<int>is the same as
instanceof Collection. If you pass aCollection<string>to a
function expectingCollection<int>, runtime accepts it because both
areCollectionat 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
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.
Nothing says we can't provide a first-party SA tool that's written in Rust instead of C.
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.
- If it's written in Rust, C++, Zig, whatever, it might make the work
easier, but it won't make it instant. - 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.
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]
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.
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]
The audience for native generics is the same audience that already
uses docblock generics, which is the same audience that already runs
SA.This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption.
As soon as PHP ships native syntax for generics, an entirely new set of users will hear about it and try to use it. Their experience is going to be shaped by what PHP itself does with that new syntax.
I would also argue that the flipside applies: if the audience for the feature really is existing users of third-party SA tools, then it shouldn't be shipped as part of php-src. The language should only provide the building blocks for those tools to work with - and attributes seem perfectly suited here.
It's not the job of this list to create standards for what attributes third-party tools support.
Rowan Tommins
[IMSoP]
What is true for I guess every PHP developer is that they are using a
production and development environment, with a different config in each
environment. Now I am not particularly fond of adding more configuration
settings, but maybe in this case a /generics_mode = erased |
bound-erased | reified/ could help to ship generics now.
As I understand the discussion on generics, the problem with reified
generics, besides the fact there is no implementation at the moment,
would be performance. My solution to this would be to /teach/, from
the start, not to use reified generics in production. Set the default of
the setting to erased or bound-erased and suggest people to enable
reified in development only. This is where good documentation and
community effort would be vital to teach this entirely new set of users
how to deal with this language feature. You would then develop in a more
strict environment than in production. It is not that this new to
developers. Developers have been transpiling their strict development
typescript and ES6 code to loose/erased JS for at least a decade now.
They have able to teach that, why would PHP not be able to teach their
decisions?
Anyway, until we have reified generics, performant or not, behind a
setting or not, I think that by teaching this new set of users what
bound-erased means and does, why it is there, by explaining which
direction the language is going, I would very much welcome bound-erased
generics. But I believe there would also have to be some kind of
consensus on the direction after bound-erased generics. That is I guess
the hardest part of this RFC. I wish Seifeddine good luck in going forward.
Regards,
Frederik Bosch
Maintainer of MoneyPHP
This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption.
The PHP 7.0 type-declaration rollout is the closest empirical test.
Native scalar types shipped to a community that had been using PHPDoc
@param and @return annotations for years. They didn't suddenly create
a new population of developers who type their code, some popular
projects argued the new types were useless and refused to adopt them.
The audience that gained native syntax was the audience that had
already been typing their code. People who didn't see value in types
before PHP 7.0 mostly didn't see value after, either.
The same dynamic will apply here. People who don't care about generic
type information today won't suddenly care because PHP grew the
syntax. The audience that uses @template in docblocks is the audience
that will use native generics.
if the audience for the feature really is existing users of third-party SA tools, then it shouldn't be shipped as part of php-src. The language should only provide the building blocks for those tools to work with - and attributes seem perfectly suited here.
Attributes themselves are a counter-example. They shipped in PHP 8.0
specifically to formalize what frameworks had been doing in docblocks.
Their audience at launch was framework users, exactly the kind of
"existing third-party-tool users" your argument says shouldn't justify
language-level support. PHP shipped them anyway, resulting in benefits
for everyone: first-class syntax, parser validation, Reflection
access, IDE support, cleaner code. The same applies to generics: the
ecosystem has been doing this work in docblocks for a decade;
formalizing it in the language is the consistent next step.
Beyond that, expressing generics through attributes specifically
doesn't work. Attribute syntax is in the form of name-call-arguments
(#[Template("T", bound: "object")]); it can't express type parameter
references, variance markers, bound-on-bound, or substitution into
method signatures. You'd end up with worse ergonomics than docblocks
for the same information, and the parser still wouldn't understand the
type relationships. The static analysis ecosystem explored and
rejected the "attributes are enough" path as too limited.
This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption.
The PHP 7.0 type-declaration rollout is the closest empirical test.
Native scalar types shipped to a community that had been using PHPDoc
@param and @return annotations for years. They didn't suddenly create
a new population of developers who type their code, some popular
projects argued the new types were useless and refused to adopt them.
The audience that gained native syntax was the audience that had
already been typing their code. People who didn't see value in types
before PHP 7.0 mostly didn't see value after, either.
I don't think this is true at all. Users were writing "array", and class/interface types, in their code for many years before PHP 7.0, and having them natively enforced. Most of those users had never heard of static analysers, but as soon as static types became available, using them was entirely natural.
If you survey current code bases, I bet you a drink of your choice that you will find code bases with some use of scalar types outnumbering code bases which have been tested with a Static Analyser by an order of magnitude.
The same dynamic will apply here. People who don't care about generic
type information today won't suddenly care because PHP grew the
syntax. The audience that uses @template in docblocks is the audience
that will use native generics.
Of course they will, because it will suddenly be much more visible. Every "what's new in PHP" blog post will describe the new syntax, and people will start playing with it. People who see docblocks as "just documentation" will see frameworks and libraries putting it in "actual types" and copy it into their own code.
And this is a good thing! We want new language features to be interesting to more people. But we also want them to be useful to those people, not documented in the manual as "advanced users only; if you're the target audience for this, you probably don't need this page".
Attributes themselves are a counter-example. They shipped in PHP 8.0
specifically to formalize what frameworks had been doing in docblocks.
Attributes are very explicitly an abstract extension point for tooling to do what it wants with. PHP does not attempt to standardise their use; it doesn't even validate that attribute names correspond to valid classes unless you ask it to. PHP provides some attributes out of the box, but only when it also includes some behaviour for those attributes.
In the same way, PHP provides the ability to define interfaces. It also provides interfaces to interact with included features, like SessionHandlerInterface. But it leaves it up to the community to agree interfaces for things that are not included with the language, like LoggerInterface or CacheInterface.
That's not the same as what you're proposing.
Beyond that, expressing generics through attributes specifically
doesn't work.
Fair enough. My point stands though: the language should provide abstract extension points, or working implementations, not empty syntax.
There's also the elephant in the room that the proposal doesn't remove the need for standardising a docblock or attribute approach anyway, because it is inevitably going to miss things the SA tools support: class-string<T>, array<int,Foo>, iterable<Bar>, non-empty-string, ...
That again follows from it not being an abstract extension point like docblocks and attributes.
Advanced users, who you say are the target audience, will still have to work with both syntaxes; and will still find differences between tools which aren't covered by the subset of validation that PHP has taken ownership of.
Rowan Tommins
[IMSoP]
This is an assumption that a lot of your reasoning seems to be based on, and as I've said already, I think it's a false assumption.
The PHP 7.0 type-declaration rollout is the closest empirical test.
Native scalar types shipped to a community that had been using PHPDoc
@param and @return annotations for years. They didn't suddenly create
a new population of developers who type their code, some popular
projects argued the new types were useless and refused to adopt them.
The audience that gained native syntax was the audience that had
already been typing their code. People who didn't see value in types
before PHP 7.0 mostly didn't see value after, either.I don't think this is true at all. Users were writing "array", and
class/interface types, in their code for many years before PHP 7.0,
and having them natively enforced. Most of those users had never
heard of static analysers, but as soon as static types became
available, using them was entirely natural.If you survey current code bases, I bet you a drink of your choice that
you will find code bases with some use of scalar types outnumbering
code bases which have been tested with a Static Analyser by an order of
magnitude.The same dynamic will apply here. People who don't care about generic
type information today won't suddenly care because PHP grew the
syntax. The audience that uses @template in docblocks is the audience
that will use native generics.Of course they will, because it will suddenly be much more visible.
Every "what's new in PHP" blog post will describe the new syntax, and
people will start playing with it. People who see docblocks as "just
documentation" will see frameworks and libraries putting it in "actual
types" and copy it into their own code.And this is a good thing! We want new language features to be
interesting to more people. But we also want them to be useful to
those people, not documented in the manual as "advanced users only; if
you're the target audience for this, you probably don't need this page".Attributes themselves are a counter-example. They shipped in PHP 8.0
specifically to formalize what frameworks had been doing in docblocks.Attributes are very explicitly an abstract extension point for
tooling to do what it wants with. PHP does not attempt to standardise
their use; it doesn't even validate that attribute names correspond to
valid classes unless you ask it to. PHP provides some attributes out of
the box, but only when it also includes some behaviour for those
attributes.In the same way, PHP provides the ability to define interfaces. It also
provides interfaces to interact with included features, like
SessionHandlerInterface. But it leaves it up to the community to agree
interfaces for things that are not included with the language, like
LoggerInterface or CacheInterface.That's not the same as what you're proposing.
Beyond that, expressing generics through attributes specifically
doesn't work.Fair enough. My point stands though: the language should provide
abstract extension points, or working implementations, not empty syntax.
I fully echo Rowan's comments. The assumption that the Venn diagram of "uses generics" and "uses PHPstan/Psalm" is a circle is presented without compelling evidence, and I don't believe it to the be case today. Even if it were the case today, I am quite confident it would not remain so for long with this RFC.
Because this RFC is providing some validation (in practice, I think it is providing most by number; it's just the turbofish that doesn't yell at you natively), users will run into generics validation messages at some point (just as they do any other syntax error). It will be confusing when some things self-validate and others do not, especially when PHP itself starts shipping stdlib code that uses generics.
Consider, if I read the RFC correctly this will (correctly) error at link time:
interface Foo<T: DateTimeInterface> {}
class Bar implements Foo<User> {}
So that part of the type system is enforced. It will therefore be natural for users to expect that
new Baz<User>(new Product $p);
will error on them. I fully get the reasons why that's way harder to do than the former. As I said, I could even be talked into accepting it for now, with appropriate communication around it, as long as there is a clear path toward enforcing it later. There's "enough" enforcement that it's not quite as bad as Python...
And basically everyone who uses PHP is going to encounter generics sooner or later. As I noted above, PHP will start shipping stdlib code that uses generics. Gina's work on generics was specifically intended to aid her previous work on Fetch API interfaces, to split ArrayAccess up into properly atomic features. Those interfaces naturally benefit from being generic, and would be implemented as such. So that means literally everyone who uses them in the future would encounter generics, and in that case, it would be enforced (at compile/link time) already. Similarly, if this passes, it will be about 4 seconds before I start working on Seq, Set, and Dict collection classes for stdlib (longer if I have trouble finding a partner for it), and those would unquestionably be generic. And that's just two obvious examples. Generics would become as much a part of the standard vocabulary of PHP developers as scalar types and interfaces. If they don't, then we've failed. :-)
But count me out for opt-in enforcement. We either enforce it or we don't. The reason strict_types exists is, largely, the billion lines of pre-existing code hat was effectively "loose" already, and people not wanting to have to fight that. Let's not create another of those situations. If call-site enforcement can be made cheap enough to include, it's cheap enough to always include. If we want to add a "disable type checks in prod" flag, that would be an ini-setting, not a code-level setting, and should be blanket for all types. It's also a completely separate question from this RFC.
Which is why I previously suggested some sort of AOT first-party checker, as a way to help ensure that whenever we do manage to add automatic enforcement, it's a minor speed bump for existing code, not a massive pseudo-BC-break, like warnings on undefined keys was.
There's also the elephant in the room that the proposal doesn't remove
the need for standardising a docblock or attribute approach anyway,
because it is inevitably going to miss things the SA tools support:
class-string<T>, array<int,Foo>, iterable<Bar>, non-empty-string, ...That again follows from it not being an abstract extension point like
docblocks and attributes.Advanced users, who you say are the target audience, will still have to
work with both syntaxes; and will still find differences between tools
which aren't covered by the subset of validation that PHP has taken
ownership of.
This is also a very valid point worth calling out. One of my more common usage patterns today is the Doctrine ORM example in the RFC (with class-string<T>). I'm still now sure how, or if, the RFC syntax would handle that case.
Is there even a viable future way to include such more-complex checks natively in the future? For some, the answer is "add that feature to PHP, duh" (like array<int, Foo>, or preferably Dict<int, Foo>), and I have some ideas for non-empty-string that people probably won't like :-), but I'm not sure what to do with class-string<T> or other more esoteric examples.
I really want to like this RFC, and I really want generics. This is likely the most viable approach that's been put forward to date. But I am still very, very nervous about the land-mines it lays in front of us if we're not careful.
--Larry Garfield
Hi Larry,
Consider, if I read the RFC correctly this will (correctly) error at link time:
interface Foo<T: DateTimeInterface> {}
class Bar implements Foo<User> {}So that part of the type system is enforced. It will therefore be natural for users to expect that
new Baz<User>(new Product $p);
will error on them.
I think there's a misreading here that's worth addressing directly.
Turbofish on new does check arity and bound. Given:
class Baz<T : DateTimeInterface> {
public function __construct(public T $date) {}
}
new Baz::<string>(new DateTimeImmutable()); // TypeError: string does
not satisfy DateTimeInterface
That's enforced at runtime at the turbofish site. What is not
enforced is the relationship between the turbofish argument and the
constructor parameter. The runtime won't reject new Baz::<DateTimeImmutable>(new DateTime()) because T erases to
DateTimeInterface for parameter-checking purposes. The user-facing
distinction is "turbofish bounds are checked, parametric relationships
across multiple uses of T are not.", That's the bound-erasure trade.
But count me out for opt-in enforcement. We either enforce it or we don't. [...] If call-site enforcement can be made cheap enough to include, it's cheap enough to always include.
The premise here is that runtime enforcement can be made cheap enough
in the foreseeable future. That's the universal-reification path, and
no implementation has yet demonstrated it works under PHP's
compilation model. Until we have a solution, "always enforce" isn't a
deferrable engineering choice, it's a constraint we currently can't
meet. If someone produces a viable design, the path forward is either
an opt-in mechanism (using the #[ReifiedGenerics] attribute, the reify
keyword, or the declare directive) or a complete switch to reified
generics, which would involve a breaking change (BC break). What I
won't commit to is "we'll just make it cheap enough later" when the
engineers who have tried haven't been able to.
I previously suggested some sort of AOT first-party checker
The same constraint applies. Building a first-party static analysis
(SA) tool competitive with PHPStan, Psalm, or Mago is a year long
project requiring a dedicated team, which we do not have. I covered
this in detail earlier in the thread. Even if the engineering were
tractable, there's no one currently positioned to do it: I'm not, the
Foundation isn't proposing it, and the proposal would be evaluated
against the existing tools that have a decade of work behind them.
The alternative is what PHP already has: four mature SA tools (Phan,
Psalm, PHPStan, Mago, in order of seniority), each maintained, each
with significant production use. PHP's documentation could point users
to these. a recommendation toward the ecosystem standard rather than
an NIH reimplementation. That's a much smaller and more tractable
thing to do than building a fifth tool.
Advanced users, who you say are the target audience, will still have to work with both syntaxes; and will still find differences between tools which aren't covered by the subset of validation that PHP has taken ownership of.
I responded to this in the Rowan reply: PHP's type system has grown
one feature per RFC for a decade, and the fact that this RFC doesn't
ship class-string<T>, non-empty-string, literal types, or negated
types isn't unique to generics; it's how every type-system RFC has
worked. Those are each their own RFC, and each can land later.
Is there even a viable future way to include such more-complex checks natively in the future?
Yes, and several of them are on the roadmap I'm planning to file once
this RFC settles: literal types (extending null/true/false to
string/int/float literals), negated types (!"" and similar), tuples,
shapes, typed arrays, and a couple of others. The intent is to
incrementally move the parts of the PHPDoc type system that PHP can
express into PHP itself, so that docblocks return to being
documentation rather than the language's type-system fallback. None of
those RFCs make sense in isolation; they need this one to land first
because they all assume the type-parameter, and generic-instantiation
that this RFC introduces.
Cheers,
Seifeddine.
Hi Seifeddine,
But count me out for opt-in enforcement. We either enforce it or we don't. [...] If
call-site enforcement can be made cheap enough to include, it's cheap enough to always
include.The premise here is that runtime enforcement can be made cheap enough
in the foreseeable future. That's the universal-reification path, and
no implementation has yet demonstrated it works under PHP's
compilation model. Until we have a solution, "always enforce" isn't a
deferrable engineering choice, it's a constraint we currently can't
meet. If someone produces a viable design, the path forward is either
an opt-in mechanism (using the #[ReifiedGenerics] attribute, the reify
keyword, or the declare directive) or a complete switch to reified
generics, which would involve a breaking change (BC break). What I
won't commit to is "we'll just make it cheap enough later" when the
engineers who have tried haven't been able to.
Even if runtime enforcement can never be made cheap enough, I believe it's
a good thing for type erased generics' syntax to clearly express the
inconsistency with regular runtime checked type hints. Runtime type
checking is a form of safety offered by PHP, and by default safety options
should be opt-out, not opt-in. Even for best practice developers using
static analysis, seeing 'erased' explicitly in the syntax might be just the gentle
reminder we need while debugging a real-world edge case.
The word 'reified' means nothing to most people other than language
designers, and whether PHP's generics are reified or monomorphised is an
implementation detail developers need not know about. #[TypeErased] has
a much better chance of being understood by developers as type unchecked
when they come across code using it.
In summary:
- If reified generics never happen, and our code is forever left with erased keywords/attributes, that's still helpful.
- If reified generics do happen, we get the choice to remove our erased keywords/attributes, that's ideal.
- The only bad scenario is reified generics do happen performantly but we have to opt-in to them everywhere.
Cheers,
Luke
If you survey current code bases, I bet you a drink of your choice that you will find code bases with some use of scalar types outnumbering code bases which have been tested with a Static Analyser by an order of magnitude.
You're probably right on raw counts. But scalar types and generics
aren't the same kind of feature: scalar types are useful in isolation
(a single int parameter benefits a single function), whereas generic
types are relational; Box<T> is useful because of the relationship
between the parameter T and the value flowing through it, and that
relationship can only be checked by something that tracks types across
call boundaries. The audience for scalar types is "anyone who wants
their function to fail loudly when called with the wrong type." The
audience for generics is "anyone who wants the type relationship
between two uses of T to be enforced." These are different
populations, and the second one corresponds tightly to people who
already run SA tools, because there is no other way to extract value
from generic type information.
Of course they will, because it will suddenly be much more visible. [...] People who see docblocks as "just documentation" will see frameworks and libraries putting it in "actual types" and copy it into their own code.
This is true of every successful language feature. Attributes
attracted users who hadn't previously used @deprecated or @Route.
Readonly attracted users who hadn't previously used final setters.
Match attracted users who hadn't previously used switch. Some of those
users used the new feature wrong before learning it; that's normal
language adoption, not a failure mode unique to generics. The bar for
shipping a feature can't be "no new user will ever misuse it," because
no PHP feature meets that bar.
Attributes are very explicitly an abstract extension point for tooling to do what it wants with. PHP does not attempt to standardise their use [...] That's not the same as what you're proposing.
PHP ships #[Attribute], #[Override], #[Deprecated],
#[SensitiveParameter], #[AllowDynamicProperties],
#[ReturnTypeWillChange], #[NoDiscard], And more. and these have
specific semantics, not abstract extension. PHP also ships interfaces
with no enforced behavior beyond a method contract: Iterator,
Countable, Stringable, ArrayAccess. The language has
historically been willing to ship type-system constructs whose
validation depends on either the runtime or external tooling, not just
abstract slots for users to define.
Native generic syntax has a similar shape: PHP ships the syntax and
the validation it can perform (compile time, link time, and turbofish
runtime, all enumerated in the RFC), while the parametric-relationship
layer requiring cross-call analysis remains where it has always been:
in SA tools. It's the same split that already governs how PHP
validates everything from array element types to callable signatures.
There's also the elephant in the room that the proposal doesn't remove the need for standardising a docblock or attribute approach anyway, because it is inevitably going to miss things the SA tools support: class-string<T>, array<int,Foo>, iterable<Bar>, non-empty-string, ...
Correct, and this RFC doesn't claim to (see "What did not collapse"
under https://wiki.php.net/rfc/bound_erased_generic_types#migrating_from_docblock_generics).
PHP's type system has been growing piecewise for a decade, scalar
types in 7.0, void and nullable in 7.1, object in 7.2, union types in
8.0, intersection types in 8.1, DNF in 8.2, true/false/null types
throughout, and there's no version of PHP that ships them all at once.
class-string<T>, literal types, negated types, type aliases, and
similar are each their own RFC, and each can be added to the language
over time. The fact that this RFC doesn't ship all of them isn't an
argument against shipping any of them; it's an argument for proceeding
incrementally, which is how PHP's type system has always evolved.
Cheers,
Seifeddine.
If you survey current code bases, I bet you a drink of your choice that you will find code bases with some use of scalar types outnumbering code bases which have been tested with a Static Analyser by an order of magnitude.
You're probably right on raw counts. But scalar types and generics
aren't the same kind of feature
It was your choice of comparison, not mine.
The audience for generics is "anyone who wants the type relationship
between two uses ofTto be enforced."
The audience for generics includes both people writing generic classes
and interfaces, and people consuming those classes and interfaces.
Take the Laravel/Eloquent Collections example mentioned in the RFC. It
didn't take me long to find this example in the documentation at
https://laravel.com/docs/13.x/eloquent#chunking-results
Flight::where('departed', true)
->chunkById(200, function (Collection $flights) {
$flights->each->update(['departed' => false]);
}, column: 'id');
Note that the callback is declared with a typed parameter of
Collection. As soon as the language allowed it, it would be natural to
instead use the generic form:
Flight::where('departed', true)
->chunkById(200, function (Collection<Flight> $flights) {
$flights->each->update(['departed' => false]);
}, column: 'id');
A novice user might at first be confused by the meaning, but a 5 minute
introduction will tell them that this means the function accepts
specifically a "Collection of Flight objects". So now, they can use it
in their own code - instead of "function getLoggedInUsers():
Collection", they'll write "function getLoggedInUsers(): Collection<User>".
They might not ever learn how to declare their own generic class, and
certainly not what "covariance", "bound-on-bound", and "parametric LSP"
mean. Crucially, they won't know what "erased generics", "monomorphized
generics" and "reified generics" mean, and why a language might include
one or another.
What they will know is that if they return something other than a
Collection object, PHP will give an error. Their natural assumption will
be that returning a Collection of something other than User objects will
also error.
So, how can we help that novice user?
Can we change the syntax slightly, to make a clearer distinction between
the enforced and unenforced parts of the type?
Can we introduce the feature in such a way that you can't use it without
also running some kind of type checker?
The bar for shipping a feature can't be "no new user will ever misuse it," because
no PHP feature meets that bar.
That's not what I was saying at all. I was saying we have to include
those new users as part of the audience for the feature, in contrast to
your repeated claims that the audience is entirely made up of people who
already use SA tools.
PHP ships
#[Attribute],#[Override],#[Deprecated],
#[SensitiveParameter],#[AllowDynamicProperties],
#[ReturnTypeWillChange],#[NoDiscard], And more. and these have
specific semantics, not abstract extension. PHP also ships interfaces
with no enforced behavior beyond a method contract:Iterator,
Countable,Stringable,ArrayAccess.
Every single one of those has behaviour implemented in PHP. Iterator is
used by foreach(), Countable by count(), and so on. You can use them
all, out of the box, and they do something.
The closest examples I can think of are the SplObserver and SplSubject
interfaces, which really don't do anything. If they were proposed now, I
don't think they'd be included; that kind of standardisation is left to
userland orgs like PHP-FIG. They're useless, but harmless. They don't
reserve any unique syntax, just a couple of class names; they don't look
like they're going to do something then not do it.
It's the same split that already governs how PHP
validates everything from array element types to callable signatures.
The difference is that right now, you can look at some code and see,
very clearly, which types are being enforced by PHP, and which are just
annotations for use by external tooling.
It's a matter of opinion whether native syntax for unenforced types
would be a good or bad thing, but it's an undeniable fact that it would
be a new thing.
--
Rowan Tommins
[IMSoP]
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.
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:
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.Hi Seifeddine,
Thanks for putting together this proposal.
However, I am generally against any unenforced implementation of generics. I choose that word carefully: it is not type erasure per se that I am against, but the ability to write code which looks like it enforces particular signatures, but actually does not.
There is lots of background in the RFC, but one relevant comparison not referenced is Python, where all type hints are unenforced. Static analysis has to be run manually, so code can be written, and even published as a library, with completely incorrect type information. Users of that code then make reasonable assumptions based on the published types, and get unexpected run-time behaviour.
Note that this is fundamentally different from the experience of languages like Java or TypeScript, where type information may be erased, but is still enforced, because static analysis is a compulsory part of the compilation process.
Having most types enforced is obviously better than none, but as far as I can see the same risk exists - someone can write code that claims to use generics, and publish it to Packagist without actually checking it; or write supposed invariants which only hold if the user also analyses their own code. This is true today, of course, but the claims are much weaker, sitting in docblocks not native syntax.
Once we go down this route, it will be very hard to change our minds, because enforcing types in code which previously ignored them would be a breaking change.
I think the only options I would personally support are:
a) run-time enforced generics in limited positions (as in the blog post you link to from Gina and Larry)
b) generics which are erased at compile-time by a tool that also enforces them (as in Java, TypeScript, etc)
Regards,
Rowan Tommins
[IMSoP]
On 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
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
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
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.
Without making things worse for anyone. But making it better for the
audience who has been using generics with no runtime enforcement for many
years. Which version would you rather write? Prior art in other languages
is the proof this makes sense.Let’s say this RFC passes and is released in the next PHP version. If I
don’t use static analysers today and a library I depend on introduces
native generics, nothing changes for me. Same way I violated @template
PHPDocs before, now I’m violating native generics instead.If someone uses @template PHPDocs today in their own code, you can assume
they almost certainly also runs a static analyser. No one is forcing anyone
to adopt all language features. Same way the match expression isn’t for
everyone, bound-erased generic types don’t have to be for everyone.But there are many PHP developers who would welcome this and have been
beating the drum for many years. The PHP documentation about this feature
could mention the compromises needed to deliver this anticipated feature.
And if someone misuses that (introduces generics in their code without
checking with static analyser), well, that's a bug they've introduced — the
same class of bug already possible today with @template.Ondřej Mirtes
On Sun, 10 May 2026 at 21:04, Seifeddine Gmati azjezz@carthage.software
wrote:
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.
- 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
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.
- 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
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
Hi Benjamin,
Thanks for thinking through this.
I'd push back on the declare approach.
I'd prefer to land with a version without declare's, but in this instance I fear we need a temporary solution to get this off the ground until all the pieces are in place.
The "temporary until all the pieces are in place" framing doesn't
quite hold. There's no committed path or timeline for reified
generics, no implementation work currently in flight, and no clear
answer to whether the runtime cost of reification can be brought to
acceptable levels. A declare added on the assumption that it
transitions to something else later would, realistically, remain in
PHP for the indefinite future. PHP doesn't remove declare directives;
declare(ticks) remains in the language decades after anyone uses it
(at least as far as I'm aware).
declare(erased_types=generic_only);
If this is not specified, then using generic syntax will throw an exception.
Requiring declare(erased_types=...) to use the syntax raises the
adoption barrier rather than lowering it. The point of native generics
over docblocks is that the syntax should be easier and more
discoverable, not require ceremony at the top of every file. A library
exposing generic APIs would need the declare in every file.
Consumers would copy-paste it as boilerplate, and new users would hit
"why isn't my generic syntax working" errors and have to learn about a
declare they didn't need to know about.
declare(strict_types=1) has already shown how "opt-in via declare"
plays out in practice. It shipped as a per-file choice but the
ecosystem turned it into mandatory boilerplate. Linter rules, style
guides, and codebase conventions all push toward "every file gets the
declare." I have a linter rule that adds it to every PHP file in my
projects, including config files that just return [...];, because
remembering when it does and doesn't matter is mental overhead I'd
rather not pay. Most serious PHP teams I know operate the same way.
Adding declare(erased_types=...) would create the same dynamic
within months, except now codebases have two declares to maintain
consistency on, and any file using generic syntax errors without it.
You'd ship a feature that's nominally opt-in but ecosystem-mandatory,
which is the worst of both worlds.
And it could give the option in the future to add two modes:
declare(erased_types=all);
declare(erased_types=none);
The granularity is wrong for the future direction you're imagining. If
reified generics ever ship, the right opt-in is per-class or
per-function, not per-file. The strictness-vs-cost tradeoff is a
code-level decision; a single class wanting reified semantics
shouldn't require everything else in the file to share that choice.
Hack made this exact decision: their reify keyword applies
per-type-parameter (function foo<reify T>(T $x)), not per-file. The
granularity matches the design space.
strict_types is the PHP precedent worth comparing to, but in a
different way than your proposal uses it. It's per-file because scalar
coercion is a clear binary with a sensible default. Generics doesn't
have a clear binary; it has a spectrum (fully erased -> bound erased
-> reified -> fully dependent?), and the design space for "what gets
enforced at runtime" is still being explored. Locking in a syntactic
mechanism now for a feature whose semantics aren't settled would
constrain future RFCs unnecessarily.
The RFC as written doesn't preclude any of the directions you're
imagining. If reified generics ever ships, it'll ship with an opt-in
mechanism that fits the granularity of the actual feature, debated and
decided at the time it's proposed. That's the right place to make that
decision, not pre-loaded into this RFC.
Cheers,
Seifeddine.
Hi Seifeddine,
Thank you so much for making and pushing for this RFC.
As the current maintainer of Psalm, I fully support the overall design of the RFC, and I especially support the choice of erased generics, considering the current state of the PHP ecosystem and of php-src itself.
Choosing erased generics has two major benefits:
- Acknowledging and building upon the currently existing rich ecosystem of static analysers, without significantly changing/breaking existing generic semantics already used by the ecosystem and by the community.
- Not locking down semantics for PHP itself by letting future RFCs and implementations do the (very) heavy lifting of runtime validation, using for example monomorphized generics, or i.e. with a static analyzer API, to plug existing static analyzers directly into PHP itself (one of the more promising approaches in my opinion, creating a true TypePHP without rewriting yet another static analyser from scratch, re-using existing, tested and working tools).
I will implement support for this RFC in Psalm immediately after the RFC is approved (fingers crossed).
I have just a few minor notes after briefly skimming the RFC and the discussion (absolute non-blockers, just a bit of bikeshedding :)
- 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)] // :(
-
-/+ 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).
-
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
Hi Daniil,
Thanks for the support. That matters a lot.
- 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.
- -/+ 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.
A few updates were made to the RFC today:
- Migration diff: full patch showing PSL's Graph package moving from
@templateto native syntax.
https://wiki.php.net/rfc/bound_erased_generic_types#migrating_from_docblock_generics - Secondary vote on variance markers:
+T/-Tvsin 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.
-
ReflectionGenericVarianceis now a unit enum.
Thanks to everyone who has reviewed.
Cheers,
Seifeddine.
Hi
Am 2026-05-10 21:02, schrieb Seifeddine Gmati:
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
Hi Tim,
Static analyzers can only prove the presence of errors, but not the absence of them. [...] PHPStan only checks usage of
mixedstarting 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 forinstanceof 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:
- 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) - 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.
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
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]
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:
- 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. - 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. - 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 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 aline 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:
- 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.- 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.- 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
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:
- 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.
- 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.
- 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?
// 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:
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:
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.
How would generic types be expressed in parameters and return types?
// Current RFC: function DoStuff<T>(myParam: T, otherParam: int): T { // ... }Existing PHP versions will have no clue what
Tis. Only one of the uses ofThere is inside<...>. Wrapping the others in#<...>would be syntactically incoherent (and rather ugly, I think). But the only alternative that comes to mind is: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:
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.
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 :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:
- 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.- 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.- 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
Le ven. 15 mai 2026 à 18:03, Larry Garfield larry@garfieldtech.com a
écrit :
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:
- 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.- 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.- 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,
NicolasI'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
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?
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:
- 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.
- 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.
- 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
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 aline 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:
- 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.- 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.- 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,
NicolasI'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
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.
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
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
Le sam. 16 mai 2026 à 20:08, Seifeddine Gmati azjezz@carthage.software a
écrit :
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.
Ah I didn't get this at first, thanks for bringing my attention there.
I was focused on clarifying an adoption path and it looks like there is one
already.
I think this is worth a new section in the RFC; telling the community how
the adoption can be made smooth (or not - aka no defaults).
Then we might need a way to deprecate relying on a default. Likely not for
this iteration.
I think you answered all my concerns. Thanks!
Nicolas
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
- Actual data on SA usage vs generics.
- 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."
- 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
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]
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.
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]
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:
/** @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
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.
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 Xwhile 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 likenew 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:
/** @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 bothAExample::produce(A): AandBExample::produce(B): B, which forces the parameter to beA | B(LSP widening on contravariant position) and the return to beA & B(LSP narrowing on covariant position to the intersection). Anything narrower violates one parent contract: returning justAbreaks theBExamplecontract; returning justBbreaks theAExamplecontract.Mago and PHPStan both accept
AandBas return types: both are wrong. Psalm rejectsAbut acceptsB: 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 declareA & 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.
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:
- 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. - Recommend turbofish at sites where the user's intent is ambiguous, so
the user can disambiguate explicitly withnew 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 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:
- 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.
- 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. IfBox<+T>declares T as covariant, thenBox<A&B>is a subtype ofBox<A>andBox<B>(intersection narrows for covariant positions). IfBox<-T>declares T as contravariant, the relationship inverts:Box<A&B>is a supertype, and aBox<A>can be passed whereBox<A&B>is expected. If T is invariant (the default), neither relationship holds and the engine treatsBox<A&B>as a distinct type fromBox<A>andBox<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 ofBox<A>orBox<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
Hi Rob,
Tested on your PR branch [...] But the entry-side check accepts the
string, contradicting the same sentence's claim that those checks read
substituted parameter types.
Good find. That's a bug in the implementation. I'll fix it before the RFC
moves to voting.
I'm a bit confused. In your earlier email you said that tools currently
can't converge on simple generics correctness [...] but say that this will
force them to converge.
The convergence claim is about what happens once the language defines a
specific semantics, not about tools agreeing on their own. Today, tools
disagree because each tool must invent a semantics for docblock generics,
and the invented semantics differ. Once PHP defines the diamond-merge rule
(or any other semantic the engine enforces), tools have a single
specification to conform to. The convergence isn't "tools deciding to
agree"; it's "tools matching the language's behavior, the same way they
match the language's behavior for scalar types, union types, intersection
types, and every other type-system feature that shipped through this same
process."
The Mago bug is evidence for the convergence claim, not against it. The bug
exists because there was no authoritative specification for the behavior;
Mago, PHPStan, and Psalm each made different choices. Under the RFC, there
is one specification (link-time diamond merge produces the parameter-union,
return-intersection signature), and tools will conform to it because the
language enforces it.
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.
Correct, and this is the trade bound erasure makes. The engine enforces the
bound; the parametric refinement is enforced at the SA layer. It's the same
trade-off already in effect for every @template annotation in production
PHP code today: the engine doesn't enforce the parametric relationship, the
SA tools do.
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.
For genuinely new developers, the path is the same as for every other
feature: documentation explains what generics do, the same way it explains
classes, inheritance, functions, types, and so on. Someone new won't be
familiar with every single feature of PHP except generics. Either:
- They know OOP in general: meaning they have already encountered generics
in Java, Scala, Kotlin, C#, or a similar language (which uses type
erasure). - They're genuinely new to programming; generics are no harder than any
other feature they'll need to learn from documentation.
200k files is a drop in the bucket when you've worked on PHP repos
spanning millions of files.
I've worked on multi-million-line PHP codebases that used @template (or
native erased generics in HackLang) extensively and strictly. The number is
a lower bound from public GitHub search, not a ceiling. Add private
codebases at Slack, Quizlet, Vimeo, and 100s of major companies running PHP
at scale, and the count grows significantly. The 200k figure is evidence of
widespread adoption, not the totality of it.
Cheers,
Seifeddine.
Hi Seifeddine,
Good find. That's a bug in the implementation. I'll fix it before the RFC moves to voting.
I'm curious how you'd fix it. The fix has to be either (a) per-child bytecode specialization (which the RFC explicitly says it doesn't do), (b) runtime indirection at every entry (which adds dispatch cost the RFC doesn't acknowledge), or (c) something else not described. The architecture section makes a specific claim about where parameter checks happen; the bug-fix commitment implies something different.
For genuinely new developers, the path is the same as for every other feature: documentation explains what generics do, the same way it explains classes, inheritance, functions, types, and so on. Someone new won't be familiar with every single feature of PHP except generics. Either:
- They know OOP in general: meaning they have already encountered generics in Java, Scala, Kotlin, C#, or a similar language (which uses type erasure).
- They're genuinely new to programming; generics are no harder than any other feature they'll need to learn from documentation.
This binary misses a significant third group: PHP developers who've used PHP for years and never had reason to learn generics from another language. They're not new to programming, but they are new to this feature, and they form mental models from PHP runtime behavior because that's how they've always used PHP. The IntBox case demonstrates exactly what their first encounter will look like: write the code, refresh, output looks correct. They are precisely the "wide range of users" the RFC template asks you to consider.
I've worked on multi-million-line PHP codebases that used
@template[...] The 200k figure is evidence of widespread adoption, not the totality of it.
I said multi-million files, not lines. They are considerably different scales, multi-million files are hundreds of millions of lines, an order of magnitude or more beyond "multi-million-line codebases." The point was about the denominator: the broader PHP footprint is much larger than the population your GitHub search covers.
On WordPress specifically (which runs 40+% of the web and is the canonical example of "PHP outside the @template ecosystem") I checked the codebase. There are exactly five files using @template. Two use @template TArrayShape of array<string, mixed> (explicitly out of scope per this RFC's own deferral). Two use @template Psr7Compatible of bool in SimplePie. One uses @template T with class-string<T> parameters (partial benefit at best, since class-string<T> itself is deferred). WordPress, even where it uses @template, would see essentially zero benefit from this RFC.
A broader search of the public PHP ecosystem shows the majority of @template usage relies on patterns this RFC explicitly defers (array<K, V>, iterable<T>, class-string<T>, etc.). The 200k figure isn't just small relative to PHP's footprint ... it overstates the population that would actually benefit from what's being proposed.
You responded to anecdotal evidence about scale with a smaller anecdote on the favored side of the ratio, presented as if it addressed the claim. It doesn't. I've already said my stance on this RFC, so I won't argue about this further. But the dismissal isn't logical, and the empirical case underneath it doesn't hold up either.
— Rob
Hi Rob
I'm curious how you'd fix it. The fix has to be either (a) per-child bytecode specialization (which the RFC explicitly says it doesn't do), (b) runtime indirection at every entry (which adds dispatch cost the RFC doesn't acknowledge), or (c) something else not described. The architecture section makes a specific claim about where parameter checks happen; the bug-fix commitment implies something different.
it's option (b), with measured cost. landed in v0.22.
The mechanism: when class IntBox extends Box<int> links, the engine
produces a substituted clone of each inherited method that has T in
its signature. The clone shares the parent's bytecode but carries its
own arg_info with T substituted to int. That part predated this
change (It existed for reflections). What changed is that the runtime
check sites now read from the clone's arg_info rather than from
compile-time-baked values.
Cost: non-generic code pays nothing at the bake/strip sites, the gates
check op_array->generic_types, which is NULL for non-generic
functions. The only cross-cutting change is RECV_INIT's
verify-after-default, which adds one helper call per default usage;
the helper short-circuits for already-validated defaults. Generic code
pays one helper dispatch per T-bearing parameter per call and one
extra verify opcode per T-bearing return path. Bench drift is within
the ±0.1% band documented in the Performance section.
Cheers,
Seifeddine.
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-stringis 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]
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:
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:
-
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. -
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.
The
~~Typeproposal raises two questions I don't see clean answers to:
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.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'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.
On the contrary, it's that partial enforcement that the idea is trying to help with.
Right now, if I write "function foo(_ $x)" then no matter what type I put in place of "_", the answer to "is it enforced?" is "yes". With the current proposal, that answer might be:
- Yes
- No: it's an unbounded type parameter which erases to "mixed"
- Partially: it's a bounded type parameter which erases to the bound; or it's a parameterised type which erases to a normal one (e.g. Box<int> to Box)
- Maybe: it's a type parameter on an abstract class, and sub-classes might be monomorphized, but might be generic themselves
The syntax won't give any hint which is which, and the same syntax in different places is enforced to different extents.
In the current shape, I will vote No, because I think that will be horrible to work with.
Shinji Igarashi suggested an "erased" keyword to leave the main syntax free for reified generics, and it got me thinking how we could generalise that to indicate the difference between "real" type declarations, and reflection-only type hints.
I'm not claiming to have a ready-to-ship proposal with all the details, just looking for a compromise that would give the standardisation and reflection capabilities Seifeddine wants, but keeps it clear to users which types are actually enforced.
Rowan Tommins
[IMSoP]
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
~~Typeproposal raises two questions I don't see clean answers to:
- 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...
- 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-ofType::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]
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
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 withassert($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 forinstanceof 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.
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 withassert($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.
Hello Internals,
Status update: RFC is now at v0.22.
Two changes are worth flagging.
- 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.
- 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.
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
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.
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
Hi Derick,
Il 25/05/2026 17:28, Derick Rethans ha scritto:
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.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
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 (newconstruct, 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 asnew
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.
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]
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
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 supportI 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.
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.
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
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
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
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
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.
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 toldIn 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]
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
’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
’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]
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.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
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.
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
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
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:reifyDisclosure: 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>; // falseWhereas 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
// BarI 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
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
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
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:
- 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.
- 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
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
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]
Hi Rob
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
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:
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
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
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
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:
- Primary, 2/3 majority required: accept the RFC as a whole.
- Secondary, simple majority, conditional on the primary passing:
variance marker syntax,+T/-T(Hack, Scala) vsin 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.
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:
- Primary, 2/3 majority required: accept the RFC as a whole.
- Secondary, simple majority, conditional on the primary passing:
variance marker syntax,+T/-T(Hack, Scala) vsin 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/21969Thanks 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:
- Erased generics, with all the limitations and problems that implies (basically Seif's current RFC).
- Monomorphized generics, with the performance hit that implies (basically Rob's patch, with some further development.)
- 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:
- Ensure it's rock solid, and as performant as we can manage
- 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.
- 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
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
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
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:
- Erased generics, with all the limitations and problems that implies
(basically Seif's current RFC).- Monomorphized generics, with the performance hit that implies
(basically Rob's patch, with some further development.)- 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:
- Ensure it's rock solid, and as performant as we can manage
- 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.- 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
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:
- Primary, 2/3 majority: accept the RFC.
- Secondary, simple majority, conditional on the primary: variance
marker syntax.
Thanks again to everyone who contributed to the discussion.
Regards,
Seifeddine.
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.