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