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

Hi Daniil,

Thanks for the support. That matters a lot.

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

I personally like Turbofish; however, I understand that some people
might not find it "pretty". But the reason it has to stay as turbofish
is parser-level: `[A<B, C>(D)]` is ambiguous between a single-element
array calling generic A and a two-element array of comparisons, and
there's no context-sensitive disambiguation that works inside
attributes, array expressions, and ternaries without introducing rules
that bite at exactly the worst places. Forcing parentheses around the
call (`[(A<B, C>(D))]`) was suggested earlier in the thread and is
uglier than turbofish; it also closes the door on tuple syntax I'd
like to keep open for a future RFC. Turbofish costs two characters and
keeps the parser non-context-sensitive, which is the trade I'd rather
take.

The `[self::foo::<Bar>(1), self::foo::<Bar>(2)]` case genuinely looks
the worst. The same call without turbofish (`[self::foo(1),
self::foo(2)]`) reads fine and is what the vast majority of call sites
will look like, since turbofish is opt-in for ambiguity resolution
(because inference is hard), and most calls won't need it.

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

Secondary vote added on this. `+T`/`-T` vs `in T`/`out T`. I have no
strong preference; the vote will decide.

I will implement support for this RFC in Psalm immediately after the RFC is approved

Side note: I'm optimistic :wink:

Thanks again for the careful read.

Cheers,
Seifeddine.

On Wed, May 13, 2026, at 12:58 PM, Rowan Tommins [IMSoP] wrote:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 PHP: rfc:bound_erased_generic_types).
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.

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.

On 13/05/2026 21:08, Seifeddine Gmati wrote:

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 of `T` to 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 Eloquent: Getting Started | Laravel 13.x - The clean stack for Artisans and agents

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]

Hi

Am 2026-05-10 21:02, schrieb Seifeddine Gmati:

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

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 `mixed` starting at level 9. Guess what level is being used by Symfony and Laravel respectively?

The level-9 framing is true today but not after this RFC ships.
Generic type information currently lives in optional levels because it
lives in optional syntax (docblocks). Once code lives in native
syntax, SA tools must treat violations of it as hard errors at low
levels. The move from optional-level checks to baseline checks is
exactly what native syntax enables.

Laravel, and Symfony use PHPStan/Psalm at lower levels today (higher
in Psalm's case) because their codebases contain code that can't all
be verified to the strictest standard without significant cleanup.
Once generics are in PHP itself, the cleanup pressure shifts: a misuse
becomes a language-level violation that any SA tool will flag at any
level, not a level-9-only concern.

What changes after this RFC is the *category* of generic violations:
they become language-level errors rather than optional-level checks.
SA tools will report them at the user's current level, not push the
user to a higher level. A Laravel codebase running PHPStan at level 5
today would achieve full generic-arity, bound, and parametric LSP
enforcement at level 5 after migrating to native generics, without
changing the level. That's the point: native syntax means the language
did the work, and tools surface violations at whatever strictness the
user already has configured.

This is typically done with `assert($foo instanceof SomeClass);`, something that PHP will double-check for you at runtime. My understanding based on the discussion is that the RFC specifically excludes support for `instanceof SomeClass<SomeType>` [...]

This is independent of the RFC. `unserialize()` returning a value of
the right type has always required user-side assertion in PHP, and
will continue to. The RFC doesn't ship `instanceof Box<int>` because
the type argument isn't carried at runtime under bound erasure, same
constraint Java, Kotlin, Scala, and Hack live with, and the same
workaround applies: assert against the bound class (`$foo instanceof
Box`), then use a docblock or static analysis for the type-argument
narrowing.

See: Generics: in, out, where | Kotlin Documentation

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
Playground · Mago,
disagreement between SA tools on specific cases is a real ecosystem
issue (this RFC's "Why people use generics" mentions this to an
extent), and tools improve over time. A false positive, or a false
negative in one tool doesn't mean that "SA-checked code is a different
language." (You will find a ton of false positives in Mago and Psalm,
too.)

So depending on the configuration the existing - third party - static analysis tools are accepting programs written in a custom programming language [...] being neither a subset, nor a superset of PHP.

This framing proves more than you'd want it to. If SA-checked PHP is
"a different language" because tools occasionally disagree with the
runtime, then by the same logic Symfony, Doctrine, Laravel, PHPUnit,
PSL, and most of the major PHP ecosystem are "not PHP". Every one of
those projects relies on SA to an extent to verify code the engine
doesn't check, including the generic type relationships that motivate
this RFC. That's a substantial portion of production PHP. Drawing the
boundary that way means one of two things:

1. The language should grow to verify everything those tools verify
(which is a much more aggressive runtime-enforcement position than
even Rowan or Larry are taking)
2. The "different language" framing is too strict.

The actual situation is that PHP has a runtime-checked layer (what the
engine validates) and an SA-checked layer (what tools verify). The two
layers complement each other. Native generics formalize a part of the
SA-checked layer that the engine can partially absorb. The
declaration-side, link-time, and turbofish-arity validation mentioned
in the RFC's enforcement section is actual engine work, not just
syntax. What's left in the SA-checked layer is the parametric-flow
analysis, which the language lacks the capability to perform.

Cheers,
Seifeddine.

On Thursday, May 14th, 2026 at 6:33 PM, Seifeddine Gmati <azjezz@carthage.software> wrote:

> But at the same time this PHP script is accepted by PHPStan despite throwing "Uncaught TypeError: foo(): Argument #1 ($bar) must be of type string, int given" at runtime [...]

That's a bug worth reporting to PHPStan. Mago catches it correctly
Playground · Mago,
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: Why Array String Keys Are Not Type-Safe in PHP | PHPStan

On 15 May 2026 00:32:05 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:

Generic type information currently lives in optional levels because it
lives in optional syntax (docblocks).

This is, quite frankly, nonsense.

If you write a function with no native type information, but an "@return int" docblock, PHPStan will report an error for a missing return statement at Level 0, and for an incorrect return statement on Level 3.

There's no relationship between the syntax needed and the types of analysis performed.

That's the point: native syntax means the language
did the work, and tools surface violations at whatever strictness the
user already has configured.

As Daniil pointed out, SA tools analyse code with offline parsers, not by loading and reflecting it; so the native enforcement of arity etc will still need to be reimplemented in each tool.

The RFC will act as a standardisation of what tools *should* enforce around those things; but that could equally be done by agreeing a set of conformance tests based on the existing docblock syntax. In fact, those conformance tests would be needed whatever the syntax, if the goal is to eliminate different handling in different tools.

The actual situation is that PHP has a runtime-checked layer (what the
engine validates) and an SA-checked layer (what tools verify). The two
layers complement each other. Native generics formalize a part of the
SA-checked layer that the engine can partially absorb.

I can agree with this framing. I think where we differ is that you see unifying those layers in one syntax as a good thing, but I see it as a bad thing: I think it is useful to be able to look at the code, and understand which parts are definitely going to be enforced by the runtime-checked layer.

I think the ideal is to somehow create a standardised syntax for the SA-checked layer, but still keep it separate from the syntax for the runtime-checked layer. If neither docblocks nor attributes are a good basis for that, maybe there's some other primitive we can add so that users can mark both the "runtime type" and the "SA type".

Straw man example:

class Foo ~~Foo<T> extends Bar ~~Bar<int,T> {
   public function foo(string ~~non-empty-string $in): array ~~list<T> {
...

Maybe PHP would process the syntax, but not the semantics, of the extra type information; SA tools would then be free to invent new pseudotypes within that framework, without needing to wait for a full PHP release cycle every time.

Rowan Tommins
[IMSoP]

If one would actually use the highest possible level of the static
analysis tools they would need to “convince” the static analyzer that
“yes, unserialize() is actually returning an object of the right type”.
This is typically done with assert($foo instanceof SomeClass);,

something that PHP will double-check for you at runtime.

Interesting that you mention specifically assert, which PHP actually does not double-check for you at runtime, in production by default, so there is actually a precedent for a language feature that only does typechecking in the development stage.

My
understanding based on the discussion is that the RFC specifically
excludes support for instanceof SomeClass<SomeType>, folks would need
to fall back to /** @var … */ comments or mark the offending line as
ignored in some other way - which basically means that even if PHP
supported generic syntax, they would need those doc comments.

While this could be a valid remark, in practice @var is considered harmful at least by Psalm, and overall I have never seen it used (with good reasons) in generic production code, simply because if your code needs to know the specific types of some generic bounds, you would have already specified them in parameter/property typehints (statically guaranteeing all consumers that require specific bound types are passed that bound); if not, there’s no point in using @var at all, and you can just keep using the generic type (appropriately specifying it in returned types and other outputs).

Kind regards,
Daniil Gentili.

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 syntax, as the RFC proposes it, doesn’t have that property: a library cannot adopt it without bumping its minimum PHP version and pulling all its consumers with it.

We’ve solved this exact problem once before for attributes. The #[…] syntax was deliberately designed so older PHP versions would parse it as a # line comment, which is what made the adoption across the ecosystem so smooth.
The same trick is available for generics if we pick the right delimiter. Concretely, write #<…> everywhere <…> appears in the current RFC: declarations, inheritance clauses, type uses in signatures, call-site arguments. One syntax, used uniformly.

Three benefits, beyond the migration story:

  1. FC for free. A library can adopt native generics in source today and continue running on older PHP versions, because the engine just sees comments. No need to coordinate with the min-PHP bump.
  2. The turbofish goes away. No need to disambiguate < from less-than comparison. With #<…>, the token is unique and unambiguous everywhere: at declaration, use, and call site. We get to drop a whole grammar mechanism rather than introduce one.
  3. Rowan’s concern is addressed typographically. Anything inside #<…> is the erased, SA-enforced layer; everything outside follows the engine’s normal runtime-checked rules.

For codebases that want to adopt native generics while still supporting earlier PHP versions, #<…> would need to sit at the end of a line so older parsers consume it as a # line comment. Code targeting only generics-aware PHP can write it inline. The line-break constraint is a transitional code-style cost, not a permanent property of the syntax, and it’s bounded by however long libraries support pre-generics versions.

WDYT? I expect Seifeddine has good reasons to prefer the current syntax. I’d like to put this on the table because the FC story it unlocks, combined with the turbofish simplification, might be worth the trade-off and might help gather a broader consensus.

Cheers,
Nicolas

On Fri, May 15, 2026 at 6:11 AM Daniil Gentili <daniil.gentili@gmail.com> wrote:

If one would actually use the highest possible level of the static
analysis tools they would need to “convince” the static analyzer that
“yes, unserialize() is actually returning an object of the right type”.
This is typically done with `assert($foo instanceof SomeClass);`,

something that PHP will double-check for you at runtime.

Interesting that you mention specifically assert, which PHP actually does **not** double-check for you at runtime, in production by default, so there is actually a precedent for a language feature that only does typechecking in the development stage.

Assertions have been on by default for a long time, and in 8.0 we
elevated them to throw instead of warn. So by default, yes, in
production you do get assertions.

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 syntax, as the RFC proposes it, doesn’t have that property: a library cannot adopt it without bumping its minimum PHP version and pulling all its consumers with it.

We’ve solved this exact problem once before for attributes. The #[…] syntax was deliberately designed so older PHP versions would parse it as a # line comment, which is what made the adoption across the ecosystem so smooth.
The same trick is available for generics if we pick the right delimiter. Concretely, write #<…> everywhere <…> appears in the current RFC: declarations, inheritance clauses, type uses in signatures, call-site arguments. One syntax, used uniformly.

Three benefits, beyond the migration story:

  1. FC for free. A library can adopt native generics in source today and continue running on older PHP versions, because the engine just sees comments. No need to coordinate with the min-PHP bump.
  2. The turbofish goes away. No need to disambiguate < from less-than comparison. With #<…>, the token is unique and unambiguous everywhere: at declaration, use, and call site. We get to drop a whole grammar mechanism rather than introduce one.
  3. Rowan’s concern is addressed typographically. Anything inside #<…> is the erased, SA-enforced layer; everything outside follows the engine’s normal runtime-checked rules.

For codebases that want to adopt native generics while still supporting earlier PHP versions, #<…> would need to sit at the end of a line so older parsers consume it as a # line comment. Code targeting only generics-aware PHP can write it inline. The line-break constraint is a transitional code-style cost, not a permanent property of the syntax, and it’s bounded by however long libraries support pre-generics versions.

WDYT? I expect Seifeddine has good reasons to prefer the current syntax. I’d like to put this on the table because the FC story it unlocks, combined with the turbofish simplification, might be worth the trade-off and might help gather a broader consensus.

Cheers,
Nicolas

Nicolas,

I think this is a brilliant idea. I was thinking that libraries would take ages to adopt this new syntax if they didn’t want to break compatibility with existing PHP versions, and your proposal solves this issue in a great way.

The price to pay is slightly uglier syntax, but we already went through that with attributes. I think that initially we all thought the attribute syntax was quite terrible, but nowadays no one bats an eye at it.

Cheers,

Carlos

On 15 May 2026, at 00:32, Seifeddine Gmati azjezz@carthage.software wrote:

PHP’s type system has grown one feature per RFC for a decade: scalar types, union types, intersection types, DNF, true/false/null types. None of those shipped the entire wishlist either. class-string<T>, integer ranges, non-empty-string, negated types, and literal types can each be their own future RFC.

On this point; I still need to finish the RFC for a literal-string type, which already exists in Psalm and PHPStan. I’ve been using Joe Watkins’ is_literal() implementation with 3 big production websites for some time, and Robert Landers has a prototype implementation as a proper type[1]. I just thought I’d give an example of a separate/small RFC.

Craig

[1] https://github.com/php/php-src/compare/master…bottledcode:php-src:add/literal-string-2

On Friday, May 15th, 2026 at 7:13 AM, Nicolas Grekas <nicolas.grekas+php@gmail.com> wrote:

I'd like to put a syntax proposal on the table that may address Rowan's concern, and that also addresses something I'm worried about independently: the migration path for existing libraries.
How does a library that uses @template docblocks today migrate to native syntax without forcing a BC break on its consumers? Multiplied across the libraries out there, this is a major point of tension for the ecosystem we need to anticipate.
Existing @template annotations were adoptable gradually *because* they're invisible to the engine.
Native <T> syntax, as the RFC proposes it, doesn't have that property: a library cannot adopt it without bumping its minimum PHP version and pulling all its consumers with it.
We've solved this exact problem once before for attributes. The #[...] syntax was deliberately designed so older PHP versions would parse it as a # line comment, which is what made the adoption across the ecosystem so smooth.
The same trick is available for generics if we pick the right delimiter. Concretely, write #<...> everywhere <...> appears in the current RFC: declarations, inheritance clauses, type uses in signatures, call-site arguments. One syntax, used uniformly.
Three benefits, beyond the migration story:

1. FC for free. A library can adopt native generics in source today and continue running on older PHP versions, because the engine just sees comments. No need to coordinate with the min-PHP bump.
2. The turbofish goes away. No need to disambiguate < from less-than comparison. With #<...>, the token is unique and unambiguous everywhere: at declaration, use, and call site. We get to drop a whole grammar mechanism rather than introduce one.
3. Rowan's concern is addressed typographically. Anything inside #<...> is the erased, SA-enforced layer; everything outside follows the engine's normal runtime-checked rules.

For codebases that want to adopt native generics while still supporting earlier PHP versions, #<...> would need to sit at the end of a line so older parsers consume it as a # line comment. Code targeting only generics-aware PHP can write it inline. The line-break constraint is a transitional code-style cost, not a permanent property of the syntax, and it's bounded by however long libraries support pre-generics versions.
WDYT? I expect Seifeddine has good reasons to prefer the current syntax. I'd like to put this on the table because the FC story it unlocks, combined with the turbofish simplification, might be worth the trade-off and might help gather a broader consensus.
Cheers,
Nicolas

How would generic types be expressed in parameters and return types?

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

On Fri, May 15, 2026, at 7:09 AM, Nicolas Grekas wrote:

Hi all,

Thanks Seifeddine for putting this together. This is an impressive
piece of work, and the general approach is sound to me. Looking forward
to where this discussion lands; I hope we'll reach enough consensus to
merge.

Le ven. 15 mai 2026 à 13:13, Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> a écrit :

On 15 May 2026 00:32:05 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:

>Generic type information currently lives in optional levels because it
>lives in optional syntax (docblocks).

This is, quite frankly, nonsense.

If you write a function with no native type information, but an "@return int" docblock, PHPStan will report an error for a missing return statement at Level 0, and for an incorrect return statement on Level 3.

There's no relationship between the syntax needed and the types of analysis performed.

> That's the point: native syntax means the language
>did the work, and tools surface violations at whatever strictness the
>user already has configured.

As Daniil pointed out, SA tools analyse code with offline parsers, not by loading and reflecting it; so the native enforcement of arity etc will still need to be reimplemented in each tool.

The RFC will act as a standardisation of what tools *should* enforce around those things; but that could equally be done by agreeing a set of conformance tests based on the existing docblock syntax. In fact, those conformance tests would be needed whatever the syntax, if the goal is to eliminate different handling in different tools.

>The actual situation is that PHP has a runtime-checked layer (what the
>engine validates) and an SA-checked layer (what tools verify). The two
>layers complement each other. Native generics formalize a part of the
>SA-checked layer that the engine can partially absorb.

I can agree with this framing. I think where we differ is that you see unifying those layers in one syntax as a good thing, but I see it as a bad thing: I think it is useful to be able to look at the code, and understand which parts are definitely going to be enforced by the runtime-checked layer.

I'd like to put a syntax proposal on the table that may address Rowan's
concern, and that also addresses something I'm worried about
independently: the migration path for existing libraries.

How does a library that uses @template docblocks today migrate to
native syntax without forcing a BC break on its consumers? Multiplied
across the libraries out there, this is a major point of tension for
the ecosystem we need to anticipate.
Existing @template annotations were adoptable gradually *because*
they're invisible to the engine.
Native <T> syntax, as the RFC proposes it, doesn't have that property:
a library cannot adopt it without bumping its minimum PHP version and
pulling all its consumers with it.

We've solved this exact problem once before for attributes. The #[...]
syntax was deliberately designed so older PHP versions would parse it
as a # line comment, which is what made the adoption across the
ecosystem so smooth.

Though recall that was an after-passage change; the original attribute syntax was << >>, which everyone apparently hated, so we changed it twice in rapid succession. (I am very very glad we did switch to the Rust-inspired syntax, but just making sure the history is clear.)

The same trick is available for generics if we pick the right
delimiter. Concretely, write #<...> everywhere <...> appears in the
current RFC: declarations, inheritance clauses, type uses in
signatures, call-site arguments. One syntax, used uniformly.

Three benefits, beyond the migration story:

1. FC for free. A library can adopt native generics in source today and
continue running on older PHP versions, because the engine just sees
comments. No need to coordinate with the min-PHP bump.
2. The turbofish goes away. No need to disambiguate < from less-than
comparison. With #<...>, the token is unique and unambiguous
everywhere: at declaration, use, and call site. We get to drop a whole
grammar mechanism rather than introduce one.
3. Rowan's concern is addressed typographically. Anything inside #<...>
is the erased, SA-enforced layer; everything outside follows the
engine's normal runtime-checked rules.

For codebases that want to adopt native generics while still supporting
earlier PHP versions, #<...> would need to sit at the end of a line so
older parsers consume it as a # line comment. Code targeting only
generics-aware PHP can write it inline. The line-break constraint is a
transitional code-style cost, not a permanent property of the syntax,
and it's bounded by however long libraries support pre-generics
versions.

WDYT? I expect Seifeddine has good reasons to prefer the current
syntax. I'd like to put this on the table because the FC story it
unlocks, combined with the turbofish simplification, might be worth the
trade-off and might help gather a broader consensus.

Cheers,
Nicolas

I'm not a huge fan of this approach. With attributes, most use cases already had a natural line-break built in. The only one that didn't was parameter attributes, and that was easy enough to work around by veritcalizing the parameter list, which is already common practice when it gets long.

In this case, what you're proposing is I'd need to write something like this:

class Foo
#<Bar>
implements Baz
#<Beep>
{
    public do(
    #Bar
    $bar) : List
    #<string>
    )
}

That's just disgusting. :slight_smile: 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

On Friday, May 15th, 2026 at 10:56 AM, Zebulan <zebulanstanphill@protonmail.com> wrote:

How would generic types be expressed in parameters and return types?

// 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)
{
    // ...
}

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.

On Fri, May 15, 2026, at 6:10 AM, Rowan Tommins [IMSoP] wrote:

On 15 May 2026 00:32:05 BST, Seifeddine Gmati <azjezz@carthage.software> wrote:

Generic type information currently lives in optional levels because it
lives in optional syntax (docblocks).

This is, quite frankly, nonsense.

If you write a function with no native type information, but an
"@return int" docblock, PHPStan will report an error for a missing
return statement at Level 0, and for an incorrect return statement on
Level 3.

There's no relationship between the syntax needed and the types of
analysis performed.

That's the point: native syntax means the language
did the work, and tools surface violations at whatever strictness the
user already has configured.

As Daniil pointed out, SA tools analyse code with offline parsers, not
by loading and reflecting it; so the native enforcement of arity etc
will still need to be reimplemented in each tool.

The RFC will act as a standardisation of what tools *should* enforce
around those things; but that could equally be done by agreeing a set
of conformance tests based on the existing docblock syntax. In fact,
those conformance tests would be needed whatever the syntax, if the
goal is to eliminate different handling in different tools.

The actual situation is that PHP has a runtime-checked layer (what the
engine validates) and an SA-checked layer (what tools verify). The two
layers complement each other. Native generics formalize a part of the
SA-checked layer that the engine can partially absorb.

I can agree with this framing. I think where we differ is that you see
unifying those layers in one syntax as a good thing, but I see it as a
bad thing: I think it is useful to be able to look at the code, and
understand which parts are definitely going to be enforced by the
runtime-checked layer.

I think the ideal is to somehow create a standardised syntax for the
SA-checked layer, but still keep it separate from the syntax for the
runtime-checked layer. If neither docblocks nor attributes are a good
basis for that, maybe there's some other primitive we can add so that
users can mark both the "runtime type" and the "SA type".

Straw man example:

class Foo ~~Foo<T> extends Bar ~~Bar<int,T> {
   public function foo(string ~~non-empty-string $in): array ~~list<T> {
...

Maybe PHP would process the syntax, but not the semantics, of the extra
type information; SA tools would then be free to invent new pseudotypes
within that framework, without needing to wait for a full PHP release
cycle every time.

This feels like it would be isomorphic to attributes, no? Syntax validated, available via reflection, but nothing done with it.

For me, my big concern is issues like what Anna Filina raised on Mastodon:

Every time we tighten up a permissive/unenforced rule to make it enforced, we alienate someone who was (wrongly? but innocently) taking advantage of that unenforcement. For the most part I have still supported those efforts, but we have to be mindful and careful with them. Adding another permissive/unenforced rule makes me nervous. (And as has been stated repeatedly, no, "everyone who matters uses SA" is not an answer.)

I'm OK with this RFC not solving every type system concern ever. non-empty-string et al, I'm fine leaving for another time, as there's a number of ways to go about them. It's the "do this but we won't enforce it, yet, maybe we will later" landmine that concerns me.

What would be convincing to me is

1. Actual data on SA usage vs generics.
2. Some proofs of concept that the problem is smaller than I am making it out to be, eg, "here's why almost no one will be dumb enough to put the wrong typed variable on the same line as the conflicting type declaration."
3. Some data on PHPStorm's market share, since in practice that would count as an SA tool (and would presumably give you a red squiggle every time you got it wrong).

Or something along those lines. As I noted in my recent blog post, the million dollar question is "what is our threshold for how many people we're OK shooting themselves in the foot with this gap, and is the predicted number that actually will below that threshold?" That's the issue that needs to be addressed, IMO.

--Larry Garfield

Le ven. 15 mai 2026 à 18:03, Larry Garfield <larry@garfieldtech.com> a écrit :

On Fri, May 15, 2026, at 7:09 AM, Nicolas Grekas wrote:

Hi all,

Thanks Seifeddine for putting this together. This is an impressive
piece of work, and the general approach is sound to me. Looking forward
to where this discussion lands; I hope we’ll reach enough consensus to
merge.

Le ven. 15 mai 2026 à 13:13, Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> a écrit :

On 15 May 2026 00:32:05 BST, Seifeddine Gmati azjezz@carthage.software wrote:

Generic type information currently lives in optional levels because it
lives in optional syntax (docblocks).

This is, quite frankly, nonsense.

If you write a function with no native type information, but an “@return int” docblock, PHPStan will report an error for a missing return statement at Level 0, and for an incorrect return statement on Level 3.

There’s no relationship between the syntax needed and the types of analysis performed.

That’s the point: native syntax means the language
did the work, and tools surface violations at whatever strictness the
user already has configured.

As Daniil pointed out, SA tools analyse code with offline parsers, not by loading and reflecting it; so the native enforcement of arity etc will still need to be reimplemented in each tool.

The RFC will act as a standardisation of what tools should enforce around those things; but that could equally be done by agreeing a set of conformance tests based on the existing docblock syntax. In fact, those conformance tests would be needed whatever the syntax, if the goal is to eliminate different handling in different tools.

The actual situation is that PHP has a runtime-checked layer (what the
engine validates) and an SA-checked layer (what tools verify). The two
layers complement each other. Native generics formalize a part of the
SA-checked layer that the engine can partially absorb.

I can agree with this framing. I think where we differ is that you see unifying those layers in one syntax as a good thing, but I see it as a bad thing: I think it is useful to be able to look at the code, and understand which parts are definitely going to be enforced by the runtime-checked layer.

I’d like to put a syntax proposal on the table that may address Rowan’s
concern, and that also addresses something I’m worried about
independently: the migration path for existing libraries.

How does a library that uses @template docblocks today migrate to
native syntax without forcing a BC break on its consumers? Multiplied
across the libraries out there, this is a major point of tension for
the ecosystem we need to anticipate.
Existing @template annotations were adoptable gradually because
they’re invisible to the engine.
Native syntax, as the RFC proposes it, doesn’t have that property:
a library cannot adopt it without bumping its minimum PHP version and
pulling all its consumers with it.

We’ve solved this exact problem once before for attributes. The #[…]
syntax was deliberately designed so older PHP versions would parse it
as a # line comment, which is what made the adoption across the
ecosystem so smooth.

Though recall that was an after-passage change; the original attribute syntax was << >>, which everyone apparently hated, so we changed it twice in rapid succession. (I am very very glad we did switch to the Rust-inspired syntax, but just making sure the history is clear.)

It was, and part of the reason why the new syntax was way better is that it was a comment to the older engine version.
Retrospectively, we can all testify how much this boosted if not just permitted broad adoption.

The same trick is available for generics if we pick the right
delimiter. Concretely, write #<…> everywhere <…> appears in the
current RFC: declarations, inheritance clauses, type uses in
signatures, call-site arguments. One syntax, used uniformly.

Three benefits, beyond the migration story:

  1. FC for free. A library can adopt native generics in source today and
    continue running on older PHP versions, because the engine just sees
    comments. No need to coordinate with the min-PHP bump.
  2. The turbofish goes away. No need to disambiguate < from less-than
    comparison. With #<…>, the token is unique and unambiguous
    everywhere: at declaration, use, and call site. We get to drop a whole
    grammar mechanism rather than introduce one.
  3. Rowan’s concern is addressed typographically. Anything inside #<…>
    is the erased, SA-enforced layer; everything outside follows the
    engine’s normal runtime-checked rules.

For codebases that want to adopt native generics while still supporting
earlier PHP versions, #<…> would need to sit at the end of a line so
older parsers consume it as a # line comment. Code targeting only
generics-aware PHP can write it inline. The line-break constraint is a
transitional code-style cost, not a permanent property of the syntax,
and it’s bounded by however long libraries support pre-generics
versions.

WDYT? I expect Seifeddine has good reasons to prefer the current
syntax. I’d like to put this on the table because the FC story it
unlocks, combined with the turbofish simplification, might be worth the
trade-off and might help gather a broader consensus.

Cheers,
Nicolas

I’m not a huge fan of this approach. With attributes, most use cases already had a natural line-break built in. The only one that didn’t was parameter attributes, and that was easy enough to work around by veritcalizing the parameter list, which is already common practice when it gets long.

In this case, what you’re proposing is I’d need to write something like this:

class Foo
#
implements Baz
#
{
public do(
#Bar
$bar) : List
#
)
}

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#
implements Baz#
{
public do(
# // ← 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#
{
// […]
}
}

That’s just disgusting. :slight_smile: 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 15 May 2026 17:11:22 BST, Larry Garfield <larry@garfieldtech.com> wrote:

Straw man example:

class Foo ~~Foo<T> extends Bar ~~Bar<int,T> {
   public function foo(string ~~non-empty-string $in): array ~~list<T> {
...

Maybe PHP would process the syntax, but not the semantics, of the extra
type information; SA tools would then be free to invent new pseudotypes
within that framework, without needing to wait for a full PHP release
cycle every time.

This feels like it would be isomorphic to attributes, no? Syntax validated, available via reflection, but nothing done with it.

Yeah, the basic concept is "attributes on types", but with a more concise syntax.

There are, I think, three basic cases such a syntax would need to handle:

- the "SA type" is the "runtime type" qualified by a generic parameter, either placeholder or concrete: e.g. Collection vs Collection<T> or Collection<int>, array vs array<Foo>
- more generally, the "SA type" is the "runtime type" with extra rules applied: e.g. string vs non-empty-string
- the "SA type" is something that just can't be expressed at all, and the "runtime type" has to be "mixed" or some other broad type: e.g. "function foo(): T", where T is a type parameter on a generic class.

For the first two, you can just suffix the extra information rather than repeating yourself, so more like this:

class Foo~<T> extends Bar~<int,T> {
    public function foo(string~non-empty $in): array~<T> {

Other than properties, annotating with "mixed" is redundant anyway, so you could have this

    public function bar(~T): ~T {

But if you wanted to enforce the lower bound, you'd have to manually include it:

    public function bar(int~T): int~T {

It's obviously not as elegant as making every type string reserved natively, but it allows for much faster iteration in SA tools, and could replace all their use of docblocks over night.

As and when the language added full runtime support for those types, users could just delete the ~ (or whatever delimiter we used) and opt in to enforcement. "class Foo~<T>" would be an erased generic, "class Foo<T>" would be a reified or monomorphized one.

Regards,

Rowan Tommins
[IMSoP]