====== PHP RFC: Bound-Erased Generic Types ====== * Version: 0.15 * Date: 2026-05-08 * Author: Seifeddine Gmati, azjezz@carthage.software * Status: Under discussion * Implementation: https://github.com/php/php-src/pull/21969 * Discussion thread: https://news-web.php.net/php.internals/130816 ===== Introduction ===== This RFC adds generic type syntax to PHP. Classes, interfaces, traits, functions, methods, closures, and arrow functions can declare type parameters; those parameters carry bounds, defaults, and variance markers; type arguments may be supplied at use sites and at call sites via turbofish. { public function __construct( public L $left, public R $right, ) {} public function swap(): Pair { return new Pair($this->right, $this->left); } } final readonly class Box<+T> { public function __construct( public T $value, ) {} public function map(callable $fn): Box { return new Box(($fn)($this->value)); } public function zip(O $value): Box> { return new Box(new Pair($this->value, $value)); } } function identity(T $value): T { return $value; } $greeting = new Box::("hello, world"); $paired = $greeting->zip::(42); $swapped = $paired->value->swap(); $result = identity::>($swapped); var_dump($result->left); // int(42) var_dump($result->right); // string(12) "hello, world" Generics in this proposal are //bound-erased//: at runtime, ''Box'' and ''Box'' are the same class, each type parameter is replaced by its declared bound (or ''mixed'' when unbounded), and the engine sees ordinary PHP types. Enforcement is split across three layers: * **Compile time** validates syntax, the 127-argument cap, and that a type parameter's default satisfies its declared bound. * **Link time** validates inheritance-clause arity (''extends''/''implements''/''use''), bound conformance with bound-on-bound for forwarded parameters, rejection of diamond inheritance with conflicting bindings, and parametric LSP propagated into property types, property hook signatures, trait method signatures, and non-overridden inherited method signatures. * **Runtime** validates arity and bounds at every turbofish site - calls, ''new'', and attribute construction. Backed property storage, property hook signatures, and trait properties enforce the substituted type. A new Reflection API exposes the pre-erasure form, so static-analysis tools read generic information directly from the engine instead of from docblock conventions. Code that does not use generics compiles to the same bytecode it does today. The rest of this RFC walks through what generics are, why PHP needs them, the prior art that shaped this design, the full proposal, the design rationale, the implementation, and what runtime models can layer on top later. ===== What are generics ===== Generics are to types what functions are to values. A function abstracts over a value: ''sum($a, $b)'' computes a result from two inputs, and the same function body applies whether ''$a'' and ''$b'' are ''1'' and ''2'' or ''100'' and ''200''. Generics abstract over a type: a generic ''Stack'' holds elements of //some// type ''T'', and the same class body applies whether ''T'' is ''int'', ''string'', or ''User''. Without generics, two unattractive options remain: * Write the same data structure once per element type (''IntStack'', ''StringStack'', ''UserStack''). The implementations are near-identical except for the type names; bug fixes have to be made in every copy. * Write a single data structure whose element type is ''mixed''. The structure works for everyone, but every site that pulls a value out of it has lost the type information and must either trust the producer or recheck. A generic type carries the type information through. Declaring ''class Stack'' introduces a //type parameter// ''T'' that stands for "the element type, whatever it is". Using ''Stack'' or ''Stack'' supplies a //type argument// for that parameter. The class body is written once and works for every choice of ''T''; static analyzers and the language itself can track the relationship between the parameter and the values that flow through it. Two operations sit at the heart of every generics system: * **Declaration**: introducing one or more type parameters on an entity (''class Stack { /* ... */ }''). Within that entity's body, ''T'' behaves like a type. * **Use**: supplying type arguments at a use site (''Stack'', ''Stack''). The compiler binds each parameter to the argument and uses that binding to type-check accesses through that instance. Different languages take different positions on what happens //after// the compiler has finished: some preserve the type argument at runtime (//reified//), some erase it after type-checking (//erased//), some generate a separate copy of the body per type argument (//monomorphized//). This RFC takes the erased path with the declared bound preserved as the runtime type; the rest of the document develops what that means concretely. The declaration-and-use distinction, however, is universal. What follows is a survey of how PHP code expresses these two operations today, in the absence of language support for them. ===== Why people use generics ===== People do use generics in PHP. They just write them in a comment. Every major framework, ORM, testing library, and standard-library project in the PHP ecosystem today carries generic type information in PHPDoc that static-analysis tools parse and validate. The information is real, the relationships are load-bearing, and the codebases break in subtle ways when the docblocks drift from the implementation. What follows is a tour of generics in production PHP code as it exists today, in projects readers will recognize. ==== Laravel: a doubly-generic collection ==== ''Illuminate\Database\Eloquent\Collection'' is the collection returned by every Eloquent query, parameterized by both key and model type and forwarding both parameters into its non-generic parent: /** * @template TKey of array-key * @template TModel of Illuminate\Database\Eloquent\Model * * @extends Illuminate\Support\Collection */ class Collection extends BaseCollection implements QueueableCollection Both parameters are bounded; the collection also declares per-method type parameters elsewhere. The static type understood by every analyzer in the ecosystem is ''Collection'' - the runtime has no idea. ==== Symfony: a covariant user provider ==== ''Symfony\Component\Security\Core\User\AttributesBasedUserProviderInterface'' declares a covariant type parameter, extends a generic parent interface, and returns the parameter from a method: /** * @template-covariant TUser of UserInterface * * @template-extends UserProviderInterface */ interface AttributesBasedUserProviderInterface extends UserProviderInterface { /** @return TUser */ public function loadUserByIdentifier(string $identifier, array $attributes = []): UserInterface; } The interface promises that ''loadUserByIdentifier'' returns the same ''TUser'' the implementing class is parameterized over; the runtime sees only ''UserInterface'', so the promise is checked entirely by static analysis. ==== PHP Standard Library: a multi-parameter graph interface ==== ''Psl\Graph\GraphInterface'' is parameterized over both node type and edge weight, with type parameters in parameter and return positions: /** * @template TNode * @template TWeight */ interface GraphInterface { /** @return list */ public function getNodes(): array; /** * @param TNode $from * @return list> */ public function getEdgesFrom(mixed $from): array; } Both parameters are unbounded - the common shape for libraries that don't constrain to a particular class hierarchy. ==== Doctrine ORM: a generic method on a non-generic interface ==== ''Doctrine\ORM\Repository\RepositoryFactory'' is itself non-generic, but its ''getRepository'' method is parameterized by the entity class: interface RepositoryFactory { /** * @param class-string $entityName * @return EntityRepository * * @template T of object */ public function getRepository(EntityManagerInterface $entityManager, string $entityName): EntityRepository; } The relationship between the input ''class-string'' and the output ''EntityRepository'' is the entire point of the API: pass ''User::class'', get back an ''EntityRepository''. Without generics, the return type collapses to ''EntityRepository'' and every caller has to re-narrow with ''instanceof''. ==== Tempest: per-method generics on a cache interface ==== ''Tempest\Cache\Cache'' is non-generic at the class level, but its multi-key operations declare per-call type parameters: interface Cache { /** * @template TKey of Stringable|string * @template TValue * * @param iterable $values * @return array */ public function putMany(iterable $values, ...): array; /** * @template TKey of Stringable|string * * @param iterable $key * @return array */ public function getMany(iterable $key): array; } These are turbofish-style use cases: the class itself is not generic, but each call benefits from a per-call parameter list. ''TKey of Stringable|string'' is a union-bounded parameter expressed in a comment the parser does not see. ==== API Platform: implementing a generic interface ==== ''ApiPlatform\State\CallableProcessor'' implements a two-parameter generic interface and forwards both parameters through: /** * @template T1 * @template T2 * * @implements ProcessorInterface */ final class CallableProcessor implements ProcessorInterface { public function process(mixed $data, Operation $operation, ...): mixed { // ... /** @var ProcessorInterface $processorInstance */ $processorInstance = $this->locator->get($processor); return $processorInstance->process($data, $operation, ...); } } Parametric LSP in the wild: the implementer is itself generic, declares two parameters, and forwards them through to the parent interface, with an inline ''@var'' annotation narrowing a value retrieved from a non-generic container. ==== PHPUnit: a constraint that returns its input ==== PHPUnit's ''Constraint'' base class uses a single-method type parameter on its invoke handler to express that the value coming in is the value going out, regardless of its type: abstract class Constraint implements Countable, SelfDescribing { /** * @template A * * @param A $actual * * @return A */ final public function __invoke(mixed $actual): mixed { Assert::assertThat($actual, $this); return $actual; } } The user writes ''$constraint($maybeUser)'' and the analyzer infers the return type is whatever ''$maybeUser'' was. Without ''@template A'' it collapses to ''mixed'' and every call site loses the narrowing. ==== Seven shapes, one syntax outside the language ==== These are seven different shapes of generic usage: * Bounded class-level parameters with inheritance forwarding (Laravel) * Covariant class-level parameter with a generic parent (Symfony) * Multiple class-level parameters on a base interface (PSL) * Per-method generics on a non-generic interface (Doctrine, Tempest) * Implementing a multi-parameter generic interface (API Platform) * Per-method narrowing on a non-generic class (PHPUnit) Seven different projects, by seven different teams, arrived independently at the same conclusion: PHP needs generics. They also, in practice, arrived at the same //syntax//: ''@template T'', optionally followed by ''of X'' for a bound; '''' arguments at use sites; inheritance forwarding tags like ''@extends'', ''@implements'', ''@template-extends'', and ''@template-implements''. A reader who can read one of these projects' generic declarations can read all of them. The convergence is real, and it is ecosystem-wide. But it didn't happen through a standards process. There is no specification for ''@template''. There is no governance, no version number, no shared test suite that defines correctness. What exists is a set of static-analysis tools - Mago, PHPStan, Psalm, Phan, and the PHPDoc parsers inside IDEs - that have, over a decade, looked at each other and said "we should support that too." When Psalm shipped ''@template-covariant'', PHPStan added it later for compatibility; when PHPStan shipped ''@template T = Foo'' for default type arguments, Mago added it because users asked for it. The arrows go in every direction; what gets adopted is whatever a critical mass of users started to write. This kind of convergence is fragile. The agreed-upon core - the part every tool reads the same way - is solid. The edges are not. Tag aliases differ (''@extends'' vs ''@template-extends'', ''@implements'' vs ''@template-implements''), edge cases in bound resolution differ, and the support level for variance, generic methods, and call-site type arguments is uneven. The differences are invisible until a refactor crosses a boundary the tools handle differently, at which point the user discovers their codebase has a hidden tool dependency in its docblocks. The largest cost, however, is structural: this syntax exists, it is in production use, and it is not in PHP. The PHP parser ignores it. The PHP runtime ignores it. The PHP Reflection API has nothing to expose of it. The community standardized; the language did not catch up. ==== The cost ==== The cost of expressing generics in a comment is paid in five recurring ways: * **The parser cannot validate the type information.** ''@template TKey of array-key'' is a string in a comment block. Misspell ''@template'' as ''@temlate'' and the type information is silently lost; PHP doesn't notice, and depending on the tool, the analyzer might not either. * **Refactors miss the docblock.** Rename a class via an IDE and the rename usually updates uses of the class name in ''.php'' files; uses inside ''@template Parent'' are not always touched. The result is a docblock that disagrees with the code, silently, until something breaks. * **Reflection has no view.** ''ReflectionClass::getName()'' returns ''Collection'', not ''Collection''. Frameworks that need generic information for serialization, dependency injection, attribute processing, or runtime metadata either reparse source files or shell out to a static-analysis tool. * **Tools disagree on what the syntax means.** Mago, PHPStan, and Psalm differ on edge cases. PHPStorm parses some forms and not others. The differences are invisible until a refactor crosses a boundary the tools handle differently. * **It splits PHP into two languages.** A reader of a generic Laravel class is reading two parallel type systems at the same time: PHP types in the signatures, PHPDoc types in the docblocks. The two often disagree: ''public function pop(): mixed'' next to ''@return T''. The reader has to know which one is authoritative for what purpose. ==== Scale ==== The pattern is not niche. A search of public PHP code on GitHub returns: * Over 202,000 files using ''@template''. * Over 3,700 files using ''@psalm-template''. * Over 2,700 files using ''@phpstan-template''. The features above are not edge cases used by a single library author. They are infrastructure, depended on transitively by the entire PHP application ecosystem. Adopting native syntax does not introduce generics into PHP - generics have been in PHP for a decade. It formalizes what is already there: a feature PHP developers use every day, expressed in the only language within reach that didn't have a place for it. ===== Userland preprocessors ===== The demand for generics is real enough that PHP developers have shipped userland transpilers to provide them. The projects exist; the adoption profile is what's instructive. They demonstrate that a userland preprocessor is not a viable substitute for language support, and the reason is structural, not a matter of implementation quality. ==== The projects ==== Anthony Ferrara's [[https://github.com/ircmaxell/PhpGenerics|PhpGenerics]] (2015, 555+ stars) hijacks the Composer autoloader and pre-processes files to transpile generic syntax, generating a synthetic monomorphized class per parameterized use. The author marked the project "Here be dragons" and recommended against production use, but the star count shows the appetite for the feature even from a self-described "black magic" implementation. Anton Sukhachev's [[https://github.com/mrsuh/php-generics|mrsuh/php-generics]] (2021) takes a similar autoloader-based approach with more recent tooling, offering both monomorphization and type-erasure modes. Implementing classes are generated into a ''cache/'' directory ahead of the main source via ''composer dump-generics''. Both work. Neither has reached anything resembling ecosystem adoption. To understand why is to understand a fact about PHP that is rarely spelled out, but that constrains what is possible at the syntax layer. ==== PHP has no build step ==== PHP has never had a build step that developers run. The runtime contract is "place your source files on disk and ''php script.php'' executes them." Every tool in the ecosystem - Composer, frameworks, IDEs, debuggers, profilers, static analyzers, linters, formatters, test runners, coverage drivers, CI pipelines, deployment platforms - is built around that contract. The source the developer writes is the source PHP executes. This is not the case for JavaScript, where every comparison usually comes from. JavaScript developers have run a build step for over a decade, and that build step pre-dates the type tooling that now rides on top of it. It exists for runtime-fragmentation reasons: browsers ship different versions of the JavaScript engine on different schedules, IE 11 lingered for years, polyfills were necessary to use language features that hadn't yet shipped to all targets, and bundling was needed to ship code in shapes the browser could load efficiently. By the time Babel was an established ecosystem norm, "I author in modern JavaScript and ship transpiled output" was the default, and adding TypeScript / Flow / JSX on top was a small marginal change. They did not introduce the build step; they slotted into one developers had already accepted on their own terms. PHP has none of these forcing functions. The runtime is the language version on the server you control. There is no fragmentation forcing source-to-source rewrites. There is no bundler, because there is no browser to bundle for. PHP developers have not adopted a build step on their own, and any feature that requires one is, in effect, asking for a behavioral change that has no analogue in the JavaScript precedent. The build step in JavaScript was earned. In PHP, asking for one is asking developers to take on a workflow they have never had a reason to take on. ==== Composer plugins cover one boundary ==== The strict claim "Composer has no build step" is too strong. A package distributed as a Composer plugin (''type: composer-plugin'', implementing ''Composer\Plugin\PluginInterface'' and an ''EventSubscriberInterface'') can subscribe to ''POST_INSTALL_CMD'', ''POST_UPDATE_CMD'', ''POST_AUTOLOAD_DUMP'', and similar events. So a preprocessor could in principle run transpilation automatically on every install or update. The trouble is that install and update are exactly the two boundaries where automation is //not// the constraint. The boundaries that matter are the rest of the developer's day. The list is long, and every entry on it is a place the Composer plugin cannot reach: * **Editor and IDE.** Autocomplete, go-to-definition, refactor-rename, type hints, inlay hints, error squiggles - all read the file in the buffer, on the keystroke. The buffer is the source the developer is typing, in the syntax the IDE doesn't natively parse. The plugin transpiles after Composer is invoked; the IDE doesn't invoke Composer. * **Static analysis.** Mago, PHPStan, Psalm, Phan run on the source tree, in CI, in pre-commit hooks, in editor extensions. None of them route through Composer's plugin events. They see the original syntax and either reject it on every line or silently lose the type information that motivated the preprocessor in the first place. The very tools the developer installed to gain generics-via-static-analysis are blinded by a preprocessor that promised them generics-via-transpilation. * **Debugger.** Xdebug attaches to the running PHP process and reports stack frames in the file/line of the running bytecode, which is the post-transpilation file. Breakpoints set in the editor buffer don't line up; stack traces in error reports point at files the developer doesn't edit. * **Coverage.** pcov and Xdebug coverage reports attribute lines to post-transpilation files. The annotations the developer reads in their editor or in the CI report are in the wrong file. * **Reflection-driven runtime tooling.** Symfony's DI compiler, Doctrine's metadata reader, Laravel's container, attribute-routed frameworks, route generators, OpenAPI generators, hydrators, serializers - they all introspect at runtime via ''ReflectionClass'', ''token_get_all'', or PHP-source parsers. They read the post-transpilation file. Error messages, file paths, line numbers, and stack traces all reference files that don't exist in the developer's editor. * **Test runner output.** PHPUnit and Pest report failures with the file and line of the executing source, which is post-transpilation. Failure messages point at lines a human cannot productively navigate to. * **Linters and formatters.** PHP_CodeSniffer, PHP-CS-Fixer, Mago's formatter parse the source-tree files. They will reject the syntax they don't understand, or pick the wrong AST and produce mangled rewrites. * **CI and deployment.** Pipelines that copy the source tree, run tests, run analyzers, run linters, build images - they run before or independently of ''composer install''. The transpiler is invisible to them unless every step is rewritten. The shape of the problem is straightforward: PHP source is read by many tools, in many places, on many cadences. ''composer install'' is one cadence, and a rare one - it fires twice a week, perhaps, in a working developer's life. The editor fires every keystroke. The static analyzer fires on every save. The debugger fires on every exception. The test runner fires on every test cycle. None of those go through Composer. A preprocessor that hooks Composer's lifecycle controls one boundary out of many, and it is not the boundary that defines the developer's experience. The "easy" parts of building a language extension - the grammar, the parser, the transpiler - are not what stops adoption. The parts that stop adoption are everything else: the integrations into every tool that touches PHP source. The preprocessor author cannot ship those integrations. They can ship their transpiler, and they can ask each tool's maintainer to add support, and the answer is correctly "we add support for PHP, not for syntax dialects of PHP." ==== Even alternative-runtime backing isn't enough ==== The strongest version of "a non-official dialect can succeed if someone with enough resources backs it" is HHVM/Hack. HHVM was an independent reimplementation of the PHP runtime, written from scratch by Facebook, with its own JIT compiler at a time when the official php-src interpreter had none; Hack was a superset language - generics, async, reified types, the feature set this RFC's Future Scope still gestures at - layered on top of that runtime. The project had everything a preprocessor doesn't: a corporate sponsor, real production scale (Facebook, Slack, Quizlet, Wikimedia briefly), and language extensions that worked end-to-end at the runtime level rather than via source rewrites. There was a window, roughly 2013 to 2018, where running PHP code on HHVM was a credible production choice, and where authoring code in Hack syntax was something an outside team could plausibly do. That window narrowed. HHVM 3.30 (December 2018) was the last release with PHP support; HHVM 4.0 (February 2019) dropped PHP entirely and went Hack-only. The ''facebook/hhvm'' repository still receives commits, synced from Facebook's internal tree, but the project's surface for outside use has shrunk: no public release schedule, no roadmap directed at external users, no path for outside contributions to land. ARM64 / Apple Silicon support has never been added, so the very large fraction of PHP developers on M-series MacBooks cannot run HHVM locally. The Docker images target Linux x86_64 only. Building from source on anything other than supported Linux distributions is in practice a research project. Hack today is, in effect, an internal Meta language used by the small set of companies that adopted it during the open window; some of those have since left. For developers outside that group, the route into Hack that briefly existed is no longer practically open. The lesson is sharper than "you need corporate backing to succeed." It is that **a non-official dialect's lifespan is bounded by the sponsor's continued external commitment**, and external commitments to language ecosystems are rarely permanent on the timescales developers plan their codebases over. The HHVM trajectory is an illustration that an alternative runtime is externally viable only as long as its sponsor keeps it externally viable, and that horizon is shorter than the lifetime of the codebases that would adopt it. The PHP community has watched this play out. The institutional memory is now that betting on a non-official PHP runtime can end with the team stranded on an implementation no one else uses, paying the migration cost back to standard PHP themselves. Any future preprocessor or alternative runtime - regardless of its technical merit, regardless of who funds it - will be evaluated against that memory: "the most heavily-resourced attempt in the language's history is no longer externally available. Why would the next attempt land differently?" The conclusion has nothing to do with the quality of the preprocessor projects above. Both ship working transpilers. Both demonstrate that the syntax-and-semantics design is not the hard part. The hard part is that there is no path for an unofficial PHP dialect to reach the level of integration that PHP developers expect from PHP, and the precedent of the most heavily-resourced attempt in the language's history shows that even abundant resources do not change this. The only path forward for a syntax extension PHP developers can actually use is for the syntax to be in PHP. ===== The tale of generics in PHP ===== Generics in PHP have been the subject of proposals, implementations, articles, conference talks, and community discussions for over a decade. This section walks the history so the present RFC can be read against what came before, and so that readers who are encountering the topic for the first time understand they are not the first. ==== 2014: type hints take root, generics surface on the mailing list ==== PHP's modern type system began landing in the early 7.x cycle (scalar declarations, return types, nullable types, void, typed properties), each as a separate RFC. In January 2014, Philip Sturgeon raised generics on internals@lists.php.net, asking whether there was interest in a formal proposal; Sara Golemon posted a summary of what HHVM and Hack already accepted for typed arrays and generics. No RFC came out of those discussions, but the topic entered the core agenda and has not left it since. In parallel, the PHP Framework Interop Group's PSR-5 (//PHPDoc Standard//) drafted a generics notation for the docblock language that PHP itself did not support. PSR-5 stalled and remains a Draft, but its grammar prefigured the ''@template'' / '''' syntax that static-analysis tools later converged on. ==== 2016 RFC: Generic Types and Functions ==== Ben Scholzen ('DASPRiD') and Rasmus Schultz proposed a reified generics design at [[https://wiki.php.net/rfc/generics|wiki.php.net/rfc/generics]] in 2016, with active internals discussion that April. The proposal covered classes, interfaces, traits, functions, and methods, with type arguments fully preserved at runtime and verified on every use. The RFC targeted PHP 7.1. The RFC has remained in Draft status for a decade despite ongoing interest and revision; no implementation accepted by internals has emerged during that time. The reified design surfaced complexity that subsequent attempts each had to confront: type checks at runtime are expensive, especially for compound types; per-instance type-argument storage costs memory; and inferring or carrying type arguments through PHP's per-file compilation model is structurally difficult. ==== 2018: static analysis fills the gap ==== While core implementation stalled, the static-analysis ecosystem advanced. Vimeo's Psalm shipped ''@template'' support and Vimeo Engineering published "[[https://medium.com/vimeo-engineering-blog/uncovering-php-bugs-with-template-a4ca46eb9aeb|Uncovering PHP bugs with @template]]", demonstrating real production wins; PHPStan followed with a compatible implementation. The two tools diverged on ''@template-covariant'' (Psalm first), defaults (PHPStan first), and a long tail of edge cases, but converged on a shared core syntax. In November, php[architect] magazine published Chris Holland's "[[https://www.phparch.com/2018/11/the-case-for-generics-in-php/|The Case for Generics in PHP]]", arguing that PHP's type-system maturity made generics the next obvious step. In August 2019, Hack (the PHP-superset language that runs on Facebook's HHVM) shipped opt-in reified generics via the ''reify'' keyword in [[https://hhvm.com/blog/2019/08/05/hhvm-4.17.0.html|HHVM 4.17.0]], sitting alongside their existing erased generics. The Hack design - opt-in reification on top of default-erased generics - is one of the runtime models this RFC's syntax remains compatible with. ==== 2020-2022: implementation attempts and IDE catch-up ==== In 2020-2021, Nikita Popov produced an experimental reified generics implementation in his fork ([[https://github.com/PHPGenerics/php-generics-rfc/issues/45|tracked at issue #45]] of the PHPGenerics repo) following on from the 2016 RFC. The implementation surfaced significant challenges: super-linear complexity in type checking for compound types, memory cost of per-instance reified type information, and the difficulty of cross-file type inference. The work was not completed and did not reach RFC stage. In parallel, JetBrains brought generics into PhpStorm "[[https://blog.jetbrains.com/phpstorm/2020/10/phpstorm-2020-3-eap-2/|based on the Psalm syntax]]", reaching close to feature parity with the static-analysis tools by the end of 2021. The ecosystem now had generics in IDEs, in static analyzers, and in docblocks - everywhere except the language. ==== 2021: the erasure path is articulated ==== In July 2021, Brent Roose published "[[https://stitcher.io/blog/we-dont-need-runtime-type-checks|We don't need runtime type checks]]" on stitcher.io, an early public articulation of the case this RFC ships: that PHP's existing type-checking model can accommodate erased generics without compromising existing invariants. The article noted that the author had not encountered a TypeError in production code in years, attributed it to static-analysis adoption, and quoted Sara Golemon estimating five years for the necessary ecosystem shift. That shift has since occurred. In late 2021, oprypkhantc opened [[https://github.com/PHPGenerics/php-generics-rfc/issues/49|issue #49]] on the PHPGenerics repository titled "Case for fully erased generics (syntax/reflection support only) for the time being". The issue argued for syntax-and-reflection-only generics on the same grounds repeated in this RFC: the docblock approach has reached its limits, the language needs to provide the syntax, runtime semantics can be added later. The discussion identified the same use cases (DI, serialization, attribute-based mechanisms), the same advantages (parser awareness, IDE support, Reflection access), and the same anticipated opposition. The 2021 discussion concluded informally that this was a viable path forward but did not produce an RFC or implementation. ==== 2022: the pattern goes mainstream ==== In March 2022, Brent Roose published a four-part series at stitcher.io ([[https://stitcher.io/blog/generics-in-php-1|1]], [[https://stitcher.io/blog/generics-in-php-2|2]], [[https://stitcher.io/blog/generics-in-php-3|3]], [[https://stitcher.io/blog/generics-in-php-4|4]]) over four days; PHPStan published its own deep guides ([[https://phpstan.org/blog/generics-in-php-using-phpdocs|Generics in PHP using PHPDocs]], [[https://phpstan.org/blog/generics-by-examples|Generics By Examples]]); DEVSENSE shipped [[https://blog.devsense.com/2022/update-php-generics/|generics support in PHP Tools for Visual Studio]]. Laravel 9 (February 2022) annotated its collection classes with ''@template'' parameters, putting generics-via-docblock in front of every new Laravel application. Doctrine, Symfony, and Tempest followed. The material covered in the previous section is the steady-state result of decisions made in this period. ==== 2023: frameworks ship generics-by-docblock as a feature ==== By 2023, generics-via-docblock had reached the conference circuit. SymfonyLive Paris 2023 hosted a "[[https://symfony.com/blog/symfonylive-paris-2023-generics-in-php|Generics (in PHP)]]" talk; IPC Munich 2023 had its own. The community-facing tone shifted from "here's a workaround" to "here's how production PHP works now." Tool authors continued to converge: PHPStorm 2023.x, Mago, and other analyzers tracked changes from PHPStan and Psalm; framework maintainers converged on a core ''@template''/''<...>'' syntax even as tag aliases varied (''@extends'' vs ''@template-extends'', ''@implements'' vs ''@template-implements''). ==== 2024: the PHP Foundation re-engages ==== At the start of 2024, under the auspices of the PHP Foundation, Arnaud Le Blanc resumed work on reified generics ([[https://github.com/arnaud-lb/php-src/pull/4|arnaud-lb/php-src#4]], "Generics experimentation"), using Nikita's earlier branch as a starting point. Many technical issues were addressed; many remained open. A significant portion of Arnaud's research went into type inference - making reified generic code ergonomic without requiring callers to spell out every type argument - and concluded that under PHP's per-file compilation model that work is "Really Really Hard." In August 2024, the PHP Foundation published "[[https://thephp.foundation/blog/2024/08/19/state-of-generics-and-collections/|State of Generics and Collections]]" by Arnaud Le Blanc, Derick Rethans, and Larry Garfield. The post described the renewed reified-generics effort, discussed a hybrid type-inference approach to reduce compile-time analysis cost, surveyed the alternatives (including erased and fully-erased declarations), and closed with five questions to the community. Two of those questions are particularly relevant to the present RFC: * "If reified generics turn out to be infeasible, would erased generics be acceptable, or should that continue to be left to user-space tooling?" * "Would 'erased generics now, and we can probably convert them to reified in the future' be an acceptable strategy, if it is determined to be feasible?" In the same month, Laracasts published a 25-minute episode by Jeremy McPeak, "[[https://laracasts.com/series/jeremys-larabits/episodes/10|Using Generics with PHP]]", demonstrating how to use generics via PHPStan, Psalm, and PHPStorm docblock annotations. The episode opens with the assessment that "PHP does not have built-in generic support, so we have to rely upon our tools," and notes that "different tools offer different levels of support." The author reports that Psalm was the only tool that handled the examples reliably; PHPStorm and the PHPStan IDE plugin both failed on common cases. The episode is teaching generics as standard PHP practice in mainstream community channels. ==== 2025: compile-time generics for class-likes ==== In mid-2025, the Foundation's Gina Banyard began work on what was first described as "associated types" - a generics-adjacent feature in which an interface or abstract class declares a placeholder type that implementing classes specify, with all substitution happening at compile time. In August 2025, the Foundation published "[[https://thephp.foundation/blog/2025/08/05/compile-generics/|Compile time generics: yay or nay?]]" by Larry Garfield and Gina Banyard, framing the work as a manually-monomorphized model restricted to interfaces and abstract classes. The trade-off is explicit: monomorphization preserves PHP's runtime type-checking invariant strictly (the substituted type is runtime-checked) at the cost of restricted scope - interfaces and abstract classes only, implementing classes must supply concrete types, no generic functions or methods, no closures or arrow functions, no turbofish at call sites. ==== Where this leaves us ==== This RFC is the first complete bound-erased generics implementation proposed to internals. The next section describes what it does. ===== Proposal ===== This section defines the feature as it ships. Each subsection is a contract: the syntax, the semantics, and the limit. ==== Declarations ==== Type parameters may be declared on: * Classes, interfaces, and traits. * Functions, methods, closures, and arrow functions. The syntax is a comma-separated list of type-parameter names enclosed in angle brackets, immediately following the entity's name (or the ''function''/''fn'' keyword for closures and arrow functions). class Box {} interface Comparable {} trait Holder {} function id(T $x): T { return $x; } class Container { public function map(callable $f): array { /* ... */ } } $pair = function(K $k, V $v): array { return [$k, $v]; }; $identity = fn(T $x): T => $x; Anonymous classes may not declare type parameters. ''new class { /* ... */ }'' is unrecoverably ambiguous with hypothetical type-argument syntax on ''new'' itself, and anonymous classes have no reuse semantics that would benefit from a parameter list. A method's type-parameter list may not shadow a class-level parameter of the same name; the engine emits a compile error to keep name resolution unambiguous. Within a single parameter list, **bounds** may reference any other parameter in the list, including a later one: ''function f()'' is well-formed, and so are mutually recursive bounds such as '', U : Box>''. **Defaults** may only reference parameters declared earlier in the same list, so that omitted arguments resolve in a single pass at instantiation time. ==== Bounds ==== A type parameter may use '':'' to declare an upper bound: interface Animal {} class AnimalBox { public function __construct(public T $value) {} } Bounds may be any valid type expression: simple types, unions (''A|B''), intersections (''A&B''), DNF (''(A&B)|C''), and pseudo-types. When omitted, the bound is ''mixed''. A bound may itself be a generic instantiation, including a recursive reference to the type parameter being declared: interface Comparable { public function compareTo(U $other): int; } class Sortable> { public function sort(array $items): array { /* ... */ } } Bounds may also reference other parameters in the same list, in any order. Forward references and mutually recursive bounds are both well-formed: interface Box {} // Forward reference: U's bound names T, which is declared after U. function f(U $x): T { /* ... */ } // Mutual recursion: T's bound names U, U's bound names T. class Pair, U : Box> {} A type parameter cannot reference itself at the //top level// of its own bound or default. Recursive references must be nested inside another type's arguments: class A {} // Error: top-level self-reference class B> {} // OK: nested in <...> ==== Defaults ==== A type parameter may use ''='' to declare a default: class Cache {} new Cache; // K = string, V = mixed new Cache::; // K = int, V = mixed new Cache::; // K = int, V = Order Defaults must be valid type expressions, and a type parameter without a default cannot follow one with a default - the same rule that governs value parameters: class Bad {} // Error: required U cannot follow optional T When a parameter declares both a bound and a default, the default must satisfy the bound. The check fires at declaration time: class Animal {} class Box {} // Error at declaration: int does not satisfy Animal The check resolves classes that are already loaded; defaults that reference forward-declared classes (still being compiled in the same translation unit) are not retroactively validated. ==== Variance ==== Type parameters default to invariant. Variance is declared with a prefix: * ''+T'' covariant ("produces" T). * ''-T'' contravariant ("consumes" T). interface Iter<+K, +V> {} interface Producer<+T> {} interface Consumer<-T> {} function transform<-I, +O>(I $input): O { /* ... */ } Markers may be declared on the type parameters of classes, interfaces, traits, functions, methods, closures, and arrow functions. A non-invariant type parameter may only appear in positions whose polarity matches its declared variance: ''+T'' may appear only in covariant (output) positions, ''-T'' only in contravariant (input) positions. Function/method return types are covariant, parameter types are contravariant, readonly and get-only-hooked properties are covariant, set-only-hooked properties are contravariant, read/write (or get+set hooked, or by-reference get hooked) properties are invariant. Bounds and defaults - both class- and function-level - are invariant positions: a non-invariant type parameter cannot appear inside any bound or default, including its own. Polarity composes through nested generics: when ''T'' appears at the i-th type-argument slot of a generic ''G<...>'', the polarity at that position is the outer polarity composed with the variance of ''G''s i-th parameter. Constructors are exempt because they are not virtually dispatched through subtyping. Violations are rejected at the declaration site. class Producer<+T> { public function take(T $x): void {} // rejected: +T in input position } class Holder<+T> { public T $val; // rejected: r/w property is invariant } class Bad<+T : Box> {} // rejected: own bound is invariant function bad<+T>(T $x): void {} // rejected: function-level rule applies uniformly For function- and method-level type parameters, the marker is a declaration-site discipline aid: PHP has no subtyping relation across function instantiations under bound erasure, so the marker has no effect on dispatch or call-site behaviour. Its sole effect is the declaration-time check, which keeps a signature like ''transform<-I, +O>(I $input): O'' from accidentally crossing input and output type parameters. Variance has no runtime effect under bound erasure beyond the declaration-time check; the markers are also exposed via Reflection for static-analysis tools. ==== Static context ==== Class-level type parameters carry an instance binding - the type argument supplied at construction or when extending the class. They cannot appear in the signature of a static method, in the type of a static property, or in the bound or default of a static method's own type parameter, because there is no instance to supply the binding. class Box { public static T $shared; // rejected public static function make(T $x): T {} // rejected public static function pick(): U {} // rejected: U's bound mentions T } Static methods may declare their own type parameters; those are unaffected because they have no instance binding to begin with. class Box { public static function id(U $x): U { return $x; } // OK } ==== Use sites ==== A named type may carry type arguments anywhere a type expression is allowed: class C { public Container $users; public Map $counts; public function get(int $id): ?User { /* ... */ } } The arguments are pre-erasure metadata, captured for Reflection. At runtime, ''Container'' is identical to ''Container''. The class-context pseudo-types ''self'', ''static'', and ''parent'' may also carry type arguments. They are aliases for the enclosing class with the parameter list it is being used at, so the arguments compose with the enclosing class's generic parameters: class C { public function f(): self { /* ... */ } public function g(): static { /* ... */ } public function h(): parent { /* ... */ } } ==== array and iterable ==== Type arguments on ''array'' and ''iterable'' are out of scope for this RFC. ''array'' is a parse error; ''iterable'' is a compile error. The semantics need design work that this RFC does not attempt. PHP's ''array'' is both a hash map and a vector at runtime, with no parametric shape that would map cleanly onto ''''. ''iterable'' is the union ''array | Traversable'', and the two branches have different key-type constraints (''int|string'' for arrays, anything for ''Traversable''). What ''array'' or ''iterable'' should mean - whether they should be enforced at runtime, what bounds the parameters carry, how variance interacts with the array's shape-polymorphism - are real questions, and the answers matter for static analysis tools that already use the syntax in docblocks. A follow-up RFC may decide they should carry type arguments and specify how. That RFC can also consider adding parametric collection types alongside or instead - for example a ''TypedArray'' class in the standard library, or a new family of array types modelled on Hack's ''dict'', ''vec'', and ''keyset'' (see [[https://docs.hhvm.com/hack/arrays-and-collections/introduction/|Hack: Arrays and Collections]], [[https://docs.hhvm.com/hack/arrays-and-collections/vec-keyset-and-dict/|vec, keyset, and dict]], [[https://docs.hhvm.com/hack/arrays-and-collections/object-collections/|object collections]]). Either direction is open. This RFC keeps its scope limited to user-declared generics on classes, interfaces, traits, functions, methods, closures, and arrow functions, and leaves the array/iterable question for a deliberate follow-up. ==== Turbofish at call sites ==== Type arguments may be supplied at call sites using the ''::<...>'' (turbofish) syntax. The token is whitespace-sensitive: ''Foo:: '' is not a turbofish - it parses as scope-resolution followed by a comparison. $user = new Box::($user); $result = filter::($users, $criteria); $cb = id::(...); $result = $obj->map::($f); $static = C::create::(); Turbofish exists primarily so static-analysis tools can disambiguate type inference at the call site, and so the parser has a non-ambiguous form for type-argument lists. At runtime, the type arguments at every turbofish site are validated for arity (against the callee's declared generic-parameter list) and for bounds (each argument must satisfy the corresponding parameter's bound). Mismatches throw ''TypeError'' or ''ArgumentCountError''. After validation the arguments themselves are discarded - they are not carried into the call frame. Turbofish is permitted on: * Function calls, method calls (including nullsafe), static method calls (including ''self::'', ''parent::'', ''static::''). * Instantiation (''new''). * Partial application and first-class callable creation. * Attribute construction (''#[Attr::<...>]''), validated when ''ReflectionAttribute::newInstance()'' is called. ==== The 127-argument cap ==== Any single declaration list or type-argument list may contain at most 127 entries. The cap applies uniformly to: * Type-parameter declarations on class, interface, trait, function, method, closure, and arrow function declarations. * Type-argument lists at use sites (named types in property/parameter/return/class-constant types, ''self<...>'', ''static<...>'', ''parent<...>''). * Turbofish at call sites (function call, method call, nullsafe, static call, ''new'', partial application, first-class callable creation). * Attribute turbofish (''#[Attr::<...>]''). * ''extends'', ''implements'', and ''use Trait'' clauses. Exceeding 127 is a compile-time error. The cap is 7-bit so a future runtime model (e.g. opt-in reified generics) can store the count in the low seven bits of a byte and reserve the top bit for a per-arity flag, without re-architecting persistence formats. No real-world code approaches this limit. ==== Reflection API ==== A new Reflection surface exposes the pre-erasure form. The runtime form remains accessible through the existing Reflection methods unchanged. final class ReflectionGenericTypeParameter implements Reflector { public string $name; public function getName(): string; public function getPosition(): int; public function getVariance(): ReflectionGenericVariance; public function hasBound(): bool; /** @throws ReflectionException if there is no bound */ public function getBound(): ReflectionType; public function hasDefault(): bool; /** @throws ReflectionException if there is no default */ public function getDefault(): ReflectionType; public function getDeclaringEntity(): ReflectionClass|ReflectionFunctionAbstract; public function __toString(): string; } enum ReflectionGenericVariance: int { case Invariant = 0; case Covariant = 1; case Contravariant = 2; } final class ReflectionTypeParameterReference extends ReflectionType { public string $name; public function getName(): string; public function getTypeParameter(): ReflectionGenericTypeParameter; } ''ReflectionTypeParameterReference'' is a ''ReflectionType'' subclass that appears //only// inside pre-erasure type expressions (the inner ''T'' of a ''Box'' returned by ''ReflectionNamedType::getGenericArguments()'', for example). It does not appear in ''ReflectionParameter::getType()'', ''ReflectionMethod::getReturnType()'', or ''ReflectionProperty::getType()'', which return the erased runtime form. Existing classes gain new methods: abstract class ReflectionFunctionAbstract { public function isGeneric(): bool; /** @return list */ public function getGenericParameters(): array; } class ReflectionClass { public function isGeneric(): bool; /** @return list */ public function getGenericParameters(): array; /** * @return list * @throws ReflectionException if this class has no parent class */ public function getGenericArgumentsForParentClass(): array; /** * @return list * @throws ReflectionException if $name is not an ancestor interface */ public function getGenericArgumentsForParentInterface(string $name): array; /** * @return list * @throws ReflectionException if $name is not a directly-used trait */ public function getGenericArgumentsForUsedTrait(string $name): array; } class ReflectionNamedType extends ReflectionType { public function hasGenericArguments(): bool; /** @return list */ public function getGenericArguments(): array; } A worked example: class Box { public function __construct(public T $value) {} } class IntBox extends Box {} $rc = new ReflectionClass(IntBox::class); $args = $rc->getGenericArgumentsForParentClass(); // $args[0] is a ReflectionNamedType for "int" $rcBox = new ReflectionClass(Box::class); $params = $rcBox->getGenericParameters(); // $params[0]->getName() === "T" // $params[0]->hasBound() === false // $params[0]->getVariance() === ReflectionGenericVariance::Invariant The ''Reflection*Arguments*'' lookups throw on non-ancestor or non-used targets rather than returning an empty list. ''getBound()'' and ''getDefault()'' throw when there is no bound or default - they are escape-hatch accessors paired with ''hasBound()'' and ''hasDefault()''. ==== Diamond inheritance ==== When the same generic ancestor is reachable via two or more paths with different bindings, the link layer admits the conflict at matching arity and merges the substituted contracts into one. The merge polarity follows the declared variance of each type parameter: * **Covariant ''+T''** positions intersect. * **Contravariant ''-T''** positions union. * **Invariant ''T''** positions fall back to use-site: return positions intersect, parameter positions union. Differing arity on the same ancestor is rejected - there is no merge that reconciles a different parameter count. For an interface diamond, the inheriting interface synthesises a single merged abstract contract that implementers must satisfy. For a class implementing two paths directly, per-path LSP additionally verifies the implementer's method against each substituted parent prototype, so an implementation that satisfies only one branch is rejected. The canonical case is an invariant ''T'' appearing in both parameter and return positions. A transformer interface that takes a value and returns one of the same shape can be implemented across a diamond by widening the parameter (contravariantly) into a union and narrowing the return (covariantly) into an intersection - one signature satisfies both substituted prototypes. interface Renderable {} interface Cacheable {} class Article implements Renderable, Cacheable {} interface Pipeline { public function process(T $value): T; } interface RenderingPipeline extends Pipeline {} interface CachingPipeline extends Pipeline {} class ArticlePipeline implements RenderingPipeline, CachingPipeline, Pipeline { public function process(Renderable | Cacheable $value): Renderable & Cacheable { if ($value instanceof Renderable && $value instanceof Cacheable) { return $value; } return new Article(); } } The merged ''process'' contract reflection sees on ''RenderingPipeline'' and ''CachingPipeline'' is ''process(Renderable | Cacheable): Renderable & Cacheable''. The ''ArticlePipeline'' implementation matches it on both paths. Property hooks merge the same way: a ''get'' hook's return type is a return position and intersects; a ''set'' hook's value parameter is a parameter position and unions. ''use Trait, Trait'' on the same class folds the second binding into the first method via the same rule. ==== What is enforced where ==== Validation is split across three layers. Each layer catches what the previous cannot. === Compile time === * **Syntax.** Type-parameter and type-argument lists must be well-formed; the 127-entry cap applies. * **Forward references in defaults.** A type parameter's default may only name parameters declared earlier in the same parameter list; bounds may reference any parameter in the list, in any order. * **Top-level self-reference.** ''class A'' and ''class A'' are rejected. * **Method/class shadowing.** ''class C { function f() }'' is rejected. * **Default vs bound.** When a parameter declares both, the default must satisfy the bound for any classes that resolve at compile time. * **Required after optional.** A required parameter cannot follow one with a default. * **Class-T in static context.** A class-level type parameter cannot appear in the signature of a static method, the type of a static property, or the bound or default of a static method's own type parameter. * **Variance.** A non-invariant type parameter may only appear in positions whose polarity matches its declared variance. Polarity composes through nested generics' parameter variance. Bounds and defaults are invariant positions; constructors are exempt. === Link time === * **Inheritance arity.** ''extends'', ''implements'', and ''use'' clauses must supply a number of arguments that fits the ancestor's declared parameters; omission is treated as zero. * **Bound conformance.** Each supplied argument must satisfy the corresponding parameter's bound. * **Bound-on-bound.** When the supplied argument is a forwarded class-scope type-parameter reference, the inheriting class's own bound on that parameter is the effective argument type for the conformance check. The forwarded parameter's bound must itself satisfy the target's bound. * **Diamond merge.** When the same generic ancestor is reachable via two paths with different bindings at matching arity, the two substituted contracts are merged use-site-variantly into one - covariant return positions intersect, contravariant parameter positions union, invariant positions fall back to use-site (return intersect, parameter union). Differing arity on the same ancestor is rejected. * **Parametric LSP.** When a child overrides a method whose declaring scope is a generic ancestor, the inherited prototype's pre-erasure types are substituted with the child's bindings before the variance check runs. Substitution propagates into property types on a class extending a generic parent, into property hook ''get'' return types and ''set'' value-parameter types on the same, into trait property types and trait method signatures when the using class supplies trait arguments, and into non-overridden inherited method signatures on a class extending a generic parent. === Runtime === * **Turbofish arity and bounds.** Validated at every turbofish site - calls, ''new'', and attribute construction. * **Substituted runtime types.** Where parametric LSP at link time produced a substituted type on a child class, that substituted type is the runtime type: backed property storage validates writes against it, property hook ''get'' / ''set'' verification uses the substituted parameter and return types, and trait properties enforce the substituted type on the using class. * **Reflection-driven attribute construction.** ''ReflectionAttribute::newInstance()'' enforces both arity and bounds against the attribute's declared generic parameters using the type arguments captured at the use site. === What is //not// checked === Arity and bound enforcement is intentionally opt-in at the consumer side, so that adding generic parameters to an existing public API is a non-breaking change for every caller that does not use turbofish. The following sites carry no arity or bound check: * **Calls without turbofish.** ''id($x)'' invokes ''function id(T $v): T'' with no validation, exactly as if ''id'' had no type parameters. * **''new'' without turbofish.** ''new Container'' on a ''class Container'' is accepted with no check; the site behaves as if ''T'' were unspecified. Inheritance is the exception and is fully checked at link time - ''extends'', ''implements'', and ''use'' validate arity and bounds against the ancestor, because the substituted shape of the inheriting class depends on those arguments and cannot be deferred. ==== Limitations ==== Bound erasure has a small set of corner cases where the runtime is less strict than the substituted signature suggests. They are listed here as part of the contract. * **Body bytecode is not recompiled per substitution.** When a child class inherits a method from a generic parent (or a class uses a generic trait) without overriding, 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. Whether the body checks the //return// type, however, is frozen at the parent's compile time: if the parent compiled against ''T : mixed'', no return-side verification runs; the substituted return type on the child is therefore not enforced when the body returns a value the parent would have accepted but the substituted type would not. The same applies to property hooks whose body was compiled against the parent's mixed view. The practical effect is bounded: backed-property storage on the child has the substituted type and rejects writes that don't match it, so any value reaching a hook body via ''$this->backing'' is already correctly typed; methods that simply shuffle ''T''-typed values around can't observe the difference. The case where the laxity is observable is a virtual hook or method whose body constructs a fresh value with a hardcoded type that doesn't match the substituted signature - rare in practice, never in code that has passed static analysis at the substituted type. * **Turbofish arguments do not tighten parameters.** ''new Box::("string")'', ''id::("string")'', and ''$obj->map::($f)'' are all accepted at the call site: the turbofish validates that ''int'' has correct arity (1) and satisfies the bound on ''T'' (''mixed'' by default for an unbounded ''T''), but the callee's ''T''-typed parameters are erased to ''mixed'' and accept the string. The type argument supplied at the turbofish does not propagate to the parameter types the runtime checks against. The behavior is the same for constructors, free functions, methods, and closures. Backed-property assignment inside a constructor will, however, reject the assignment if the property's storage type was substituted (''public T $value'' on a class extending ''Box'' becomes ''public int $value'' on the substituted view). * **Method-level type parameters with class-T bounds use the bound, not the instantiation.** ''class Box { function pick() }'' - calling ''pick::'' on ''new Box::'' succeeds (''Cat'' satisfies ''Animal'' even though the class is ''Box''). This is consistent with bound erasure: ''T'' inside the method's bound expression resolves to ''T''-the-erased-type (''Animal''), not ''T''-the-particular-instantiation (''Dog''). These cases are bounded, documented, and reachable only by code that static analysis would already have flagged. They are the trade made by choosing bound erasure over reified generics, and they sit in the same position the existing ''@template''-via-docblock model has occupied for the past decade. ===== Design choices ===== This section explains why each significant decision was made and what was considered. Readers familiar with one of the listed alternatives can jump directly to the relevant subsection. ==== Why bound erasure ==== The runtime model has three viable shapes: * **Reified.** Each instance carries its type-argument binding at runtime; ''instanceof'' and method calls validate against the binding. Strong runtime guarantees, at the cost of per-instance memory, per-call overhead, and unsoundness when reified and erased callers meet at API boundaries. * **Fully erased to ''mixed''.** Types are erased to ''mixed'' regardless of declared bounds; the runtime sees only ''mixed''. Cheap; preserves no runtime safety on bounded parameters. * **Bound erasure.** Each type parameter is replaced by its declared bound (''mixed'' when unbounded). The runtime sees ordinary PHP types that it already knows how to check. Bound erasure preserves PHP's existing type-checking model. The runtime already does not check the element type of ''array'', the element type of ''iterable'', the parameter or return signatures of ''callable'', or the contents of ''mixed''. In each case the runtime checks what it can and static-analysis tools provide the layer of verification the runtime does not. Generic parametricity ("the relationship between two uses of the same ''T''") fits this same split: the //bound// is a PHP type and is checked at runtime under PHP's normal rules; parametricity is checked by the static-analysis layer that has handled it for a decade. The model has prior art at scale. Java has shipped erased generics since 2004. Two decades of production use across enterprise codebases larger than the typical PHP codebase have not produced the runtime debugging crisis erasure is sometimes predicted to cause. Scala and Kotlin, JVM languages with type systems considerably more rigorous than PHP's, also use erasure as their primary generics model - [[https://kotlinlang.org/docs/generics.html#type-erasure|Kotlin's documentation describes the same trade-off explicitly]], and offers the same kind of opt-in escape hatch (''reified'' type parameters on ''inline'' functions) that a future RFC could layer on top of this one. Hack ships erased generics by default with opt-in reification on top. The choice of bound erasure also keeps the door open. Future runtime models - opt-in reified generics via attribute, monomorphization for class-likes, anything else - can layer on top of the syntax and metadata this RFC ships, without changing how erased declarations behave for code that does not opt in. Reified generics //replace// the bound; bound erasure does not foreclose them. ==== Why ''<...>'' for type parameters ==== Angle brackets are the convention in TypeScript, Java, C#, Kotlin, Rust, Swift, C++, and Hack. PHP developers reading modern code already encounter the syntax daily; the static-analysis ecosystem already uses it in docblocks; PHPStorm parses it; every alternative would require teaching PHP developers something other than what they already write. The implementation handles the parsing ambiguities (right-angle splitting at ''>>'', comparison disambiguation) without a new keyword or sigil. ==== Why ''::<...>'' at call sites ==== PHP's expression grammar uses ''<'' for less-than, and the ambiguity is not hypothetical: [A(C)] Without a distinguishing token, this can parse as either a two-element array (''A < B'' and ''B > (C)'') or a single-element array calling a generic ''A'' with ''C''. The same shape recurs in many expression contexts. Rust resolved this by introducing a token before the angle bracket - ''::<...>'', the //turbofish// - and we adopt the same shape because it requires only a single new lexer token and the precedent is established. Hack uses the same token for the same reason. ==== Why '':'' for bounds ==== '':'' is the convention in Hack, Kotlin, and Scala. ''extends'' reuses an existing PHP keyword in a non-inheritance role and is visually confusing next to actual class-to-class inheritance (''class C extends D'' reads cleanly; ''class C extends D'' does not). ''is'' would reserve a new keyword and is unfamiliar in this position. ==== Why ''+T'' / ''-T'' for variance ==== ''+''/''-'' is the variance syntax used by Hack and Scala. ''out''/''in'' (C#, Kotlin) require reserving two new contextual keywords. ''+'' and ''-'' require no new keywords at all, which minimizes friction for code that already uses those identifiers. The Hack precedent makes this the familiar form for the largest body of PHP-adjacent generic code. ==== Why ''='' for defaults ==== ''='' matches PHP's existing syntax for value-parameter defaults. The mental model "right-of-equals is the fallback" is already learned. Both PHPStan and Mago support ''@template T = mixed'' for default syntax in docblocks today, so the form is also already familiar to anyone using generics in PHP. ==== Why parametric LSP at link time ==== When a child class inherits or overrides a method declared in a generic ancestor, variance has to be checked using the ancestor's pre-erasure types substituted with the child's binding. The classical solution is //bridge methods// - generated adapter functions that the child publishes alongside its real method. Java emits bridge methods at the bytecode level: each child gets a small synthetic method that receives the substituted parameter types, casts, and dispatches to the real one. PHP can do this differently. The variance check happens at link time, in C, with full access to both the ancestor's pre-erasure type table and the child's binding side table. We can substitute the ancestor's type into a local zend_type before calling the existing variance comparator, without generating any bytecode. Substitution is a few pointer-and-array operations; it has zero runtime cost; it integrates with the existing delayed-variance-obligation system used for unresolved class loads. The cost of this choice is that the substituted view is not present in the //body bytecode// of the inherited or trait-imported method. The signature is substituted on the child's clone; the entry-side and exit-side type checks embedded in the body were compiled at the parent's compile time, against the parent's view. The runtime laxity that follows is documented in the Proposal section. Bridge methods would close that gap at the cost of a separate function and call frame per substitution. The trade is between a small documented runtime laxity (closable by future work) and a permanent per-substitution call-frame cost on every inherited method invocation. We chose the former. ==== Why a single check site for runtime ==== The runtime check is structured so that code which does not use turbofish pays nothing for it. We considered two alternatives that would distribute the check more broadly: a side-table consulted on every call, and a per-callee flag tested in the call dispatch path. Both showed measurable regression on the bench suite, because they levy a cost on every call regardless of whether the callee uses generics at all. Concentrating the check at the turbofish sites where type arguments actually appear means non-turbofish code keeps the exact runtime profile it has today, while turbofish sites pay an arity-and-bounds check whose cost is dominated by the type comparison itself, not by dispatch overhead. ==== Why 127 ==== A 7-bit cap is chosen for two reasons: * **Forward compatibility.** A future RFC may revisit the slot-packing question, or introduce a separate runtime model (opt-in reified generics, monomorphization for class-likes, anything else) that wants to store the arity per-instance, per-frame, or per-site. Capping at 127 lets the arity fit in the low seven bits of a byte and leaves the top bit free for a per-arity flag - boxed-vs-unboxed, reified-vs-erased, or any other mode marker that a future model needs - without re-architecting persistence formats later. * **Ergonomic cost is zero.** The largest type-parameter list found in a public PHP codebase is on the order of 8 parameters; 127 is orders of magnitude beyond observed usage. ==== Why three enforcement sites ==== Arity and bounds are enforced at compile time, at link time, and at runtime. The three layers exist because each catches what the previous cannot: * **Compile time** sees the source. It catches typos, syntactic mistakes, default-vs-bound violations on already-loaded classes, and the 127-cap. * **Link time** sees the inheritance graph. It catches inheritance arity mismatches on classes whose parents weren't loaded at compile time, bound conformance on declared use-clauses, diamond inheritance with conflicting bindings, and cross-class variance violations via parametric LSP. * **Runtime** sees the turbofish site. It catches arity and bound mismatches at dispatch sites where the callee was not statically known at compile time (dynamic method calls, reflection-driven attribute construction, callable invocations). PHP's existing value-argument arity check fires only at runtime, via ''ArgumentCountError'' - the parser never rejects ''f(1, 2)'' even when ''f'' is statically known. Generics get more layers because the information available is richer: type parameters and bounds //are// in source, in inheritance side tables, and on the call frame, in different forms in different places. Catching mismatches at the earliest layer that sees them - compile time for syntax, link time for inheritance, runtime for dispatch - keeps each layer's diagnostics local to the source the user can act on. ===== Future Scope ===== The runtime model shipped by this RFC is bound erasure with link-time parametric LSP. The syntax, the metadata, and the Reflection API are designed so that other runtime models can layer on top without breaking existing erased code. This section enumerates the directions that visibly fit on top, with explicit pointers to which work this RFC already does and which work a future RFC would still need to do. ==== Body-level monomorphization to close the runtime-laxity gap ==== This RFC already does //signature-level// substitution on every site where parametric LSP is needed: properties on a class extending a generic parent, property hook ''get'' / ''set'' signatures, trait properties, trait methods, and non-overridden inherited methods. Each site gets a per-class clone of the inherited member with a substituted signature. Reflection sees the substituted view, link-time variance uses it, and the entry-side parameter checks inside the body read against it. What this RFC does //not// do is recompile the //body bytecode// per child. Bodies are compiled once at the parent's compile time, against the parent's pre-erasure view, and shared across children. The result is the documented runtime laxity: when the parent compiled against ''T : mixed'', no return-side verification was emitted; the substituted return type on the child is therefore not enforced inside the body. (Backed-property storage closes most of the gap in practice because the storage's type is substituted on the child; the laxity is observable only when a body constructs a fresh value with a hardcoded type that doesn't match the substituted signature.) A future RFC could close the gap by recompiling the body per substitution site. The shape of the work: * Walk each member that already gets a signature-level clone, alongside the pre-erasure side-table that holds the original parameter and return types. * Rerun the body-emission pass that lays down parameter-receive and return-verify checks, this time against the substituted signature. * Allocate a per-child copy of the body, refcounted independently, so that constructor turbofish and method-T-bound mismatches reach the new check. * Wire it through opcache (each per-child body persists separately), JIT (per-child specializations), and Reflection (the body-recompiled clone reports the same signature it does today). The PHP Foundation's 2025 //Compile time generics// proposal (Banyard and Garfield) does exactly this body-level monomorphization for a restricted scope: interfaces and abstract classes, where the implementing class supplies concrete type arguments. Their work and this work are compatible: this RFC ships the syntax, metadata, and parametric-LSP signature substitution; their proposal could add body recompilation on top, restricted to the class-likes their analysis covers cleanly. The two land at "syntax + signature-level substitution + body-level substitution where structurally tractable." The choice this RFC makes is to ship the broad-scope, signature-level work first. The body-level work is a //continuation//, not a prerequisite, and not a rewrite. ==== Opt-in reified generics ==== A future RFC could introduce ''#[ReifiedGenerics]'' as an opt-in class-level attribute: #[ReifiedGenerics] class Box { /* ... */ } Reified-marked classes would carry their type-argument binding at runtime. ''instanceof'', method dispatch, and parameter checks consult the binding, the same way Hack's opt-in reified does. The cost (per-instance binding, per-check comparison) is paid only by classes that opt in. The infrastructure built by this RFC is intentionally suitable as a foundation: * The pre-erasure metadata side table already records every type parameter, every bound, every default, every inheritance binding. * The Reflection API already exposes the pre-erasure form. * Turbofish arguments at call sites and at attribute use sites are already captured by the engine. * The link-time parametric LSP already handles the cross-class variance correctly. A reified RFC would add: per-instance type-argument storage on objects of opted-in classes, a runtime check at ''instanceof'', and the runtime work to consult the binding at type-check sites. None of that requires unwinding what this RFC ships. ==== Where clauses, higher-kinded parameters, inference improvements ==== The syntax shipped here covers the largest category of generic usage in PHP today, but it does not exhaust the design space. Features that could build on top, each on its own RFC and its own merits: * **''where'' clauses** for additional bounds beyond what '':'' supports - useful for expressing relationships between multiple type parameters (''where T : Comparable''). * **Higher-kinded parameters** - type parameters whose own arity is itself parameterized. Rare in practice and rarely cleanly orthogonal; would need its own justification. * **Inference improvements at call sites.** Today turbofish arguments are explicit; a future RFC could add limited inference (recovering the type argument from value arguments where unambiguous) without introducing the cross-file inference cost that Arnaud's 2024 reified-inference research surfaced. * **Type aliases.** Independent of generics, but the two interact naturally once both exist (''type Result = T | Error;''). ==== Why shipping this RFC is the path forward ==== The runtime-model question - reified, monomorphized, erased - is real, and the PHP community has been debating it for over a decade. None of the runtime models can be evaluated in the abstract; each requires the syntax, the parser support, the metadata representation, the Reflection surface, and the link-time correctness machinery to even have something to layer on. This RFC ships those pieces. That matters in two directions: * **Forward compatibility.** Reified generics, body-level monomorphization, and any other runtime model proposed in future RFCs would all layer on the same foundation. They differ in what they do //after// the link-time substitution this RFC already performs; they do not differ in what they need //from// the language. Shipping this RFC unblocks all of them simultaneously. * **Not shipping this RFC does not bring future runtime models any closer.** It only delays them. Every future generics RFC then has to relitigate the syntax bikeshed, the Reflection surface, the metadata representation, the parametric-LSP question, and the bound-erasure trade-off, alongside whatever runtime model it proposes. The 2016 RFC stalled. The 2020-2021 reified attempt stalled. The 2024 reified continuation stalled on cross-file inference. The 2025 compile-time-generics proposal scopes itself narrowly to avoid relitigating the broader syntax. Each attempt has paid the syntax cost and produced no syntax. The pattern is unlikely to break itself. ===== Backward Incompatible Changes ===== None at the user level. All new syntax was previously a parse error. ===== Proposed PHP Version(s) ===== Next PHP 8.x. ===== RFC Impact ===== ==== To the ecosystem ==== PHP-resident static-analysis tools (Mago, PHPStan, Psalm, Phan) gain a first-class source of generic type information through Reflection, removing the need for tool-specific stubs and parser extensions. The semantic model proposed here matches the model already used by these tools in production: existing ''@template'' annotations migrate to native syntax mechanically, and code that does not migrate continues to work unchanged. Native generic syntax and ''@template'' annotations may coexist in the same codebase; tools that understand both can read either form. Userland libraries gain language syntax for what they currently express in docblocks. Maintainers of major frameworks and libraries - Laravel, Symfony, Doctrine, PSL, API Platform, PHPUnit, Tempest, and others covered in the "Why people use generics" section - have run on generics-via-docblock for years and stand to remove an accidental dependency on PHPDoc conventions whose subtle differences leak into their codebases through the analyzers they depend on. IDEs, language servers, auto-formatters, and linters that today special-case ''@template'' parsing can read the new syntax through the parser and Reflection rather than through ad-hoc PHPDoc handling. The "hidden tool dependency" described earlier in this RFC becomes a visible language feature. ==== To existing extensions ==== Internal data structures for classes, functions, attributes, and the AST gain appended fields and child slots for generic metadata; existing fields keep their offsets, and the new fields are absent on non-generic entities. A new VM opcode is added; it is emitted only at turbofish sites, so bytecode for code that does not use turbofish is byte-identical to today's. Extensions that interact with the AST, with class-entry layout via internal accessors, or that re-implement opcode dispatch should consult ''UPGRADING.INTERNALS''. ==== To SAPIs ==== None. ==== To opcache ==== Generic metadata persists through both opcache SHM and the file cache; no user-visible behavior changes for cached scripts. ===== Vote ===== Primary Vote requiring a 2/3 majority to accept the RFC: * Yes * No * Abstain ===== Patches and Tests ===== Implementation: [[https://github.com/php/php-src/pull/21969]] ===== References ===== ==== Generics in other languages ==== * Hack: [[https://docs.hhvm.com/hack/generics/introduction/]] (erased generics by default), [[https://docs.hhvm.com/hack/reified-generics/introduction|reified-generics introduction]] (opt-in reified). * Java: [[https://docs.oracle.com/javase/tutorial/java/generics/genTypes.html|The Java Tutorials: Generic Types]]. * Rust (turbofish): [[https://doc.rust-lang.org/reference/glossary.html#turbofish]]. * Scala: [[https://docs.scala-lang.org/tour/generic-classes.html]]. * Kotlin: [[https://kotlinlang.org/docs/generics.html]] (and specifically [[https://kotlinlang.org/docs/generics.html#type-erasure|the type-erasure section]]). * TypeScript via Node.js (type stripping): [[https://nodejs.org/api/typescript.html#type-stripping]]. ==== Static-analysis tools ==== * Mago: [[https://mago.carthage.software/]]. * PHPStan: [[https://phpstan.org/]] - in particular [[https://phpstan.org/blog/generics-in-php-using-phpdocs|Generics in PHP using PHPDocs]] and [[https://phpstan.org/blog/generics-by-examples|Generics By Examples]]. * Psalm: [[https://psalm.dev/docs/annotating_code/templated_annotations/|Template Annotations]]. * Phan: [[https://github.com/phan/phan/wiki]]. * PhpStorm: [[https://blog.jetbrains.com/phpstorm/2021/07/phpstorm-2021-2-release/|PhpStorm 2021.2 generics support]]. * DEVSENSE PHP Tools for Visual Studio: [[https://blog.devsense.com/2022/update-php-generics/|update: support for generics]]. * Vimeo Engineering Blog: [[https://medium.com/vimeo-engineering-blog/uncovering-php-bugs-with-template-a4ca46eb9aeb|Uncovering PHP bugs with @template]]. ==== Prevalence of docblock generics ==== * [[https://github.com/search?q=%22%40template%22+lang%3Aphp&type=code|''@template'' on GitHub]]: 202,000+ files. * [[https://github.com/search?q=%22%40psalm-template%22+lang%3Aphp&type=code|''@psalm-template'' on GitHub]]: 3,700+ files. * [[https://github.com/search?q=%22%40phpstan-template%22+lang%3Aphp&type=code|''@phpstan-template'' on GitHub]]: 2,700+ files. ==== Prior PHP work ==== * [[https://wiki.php.net/rfc/generics|2016 RFC: Generic Types and Functions]] (Scholzen, Schultz). * [[https://github.com/PHPGenerics/php-generics-rfc/issues/45|PHPGenerics issue #45: Implementation notes]] (Nikita Popov's 2020-2021 reified branch). * [[https://github.com/PHPGenerics/php-generics-rfc/issues/49|PHPGenerics issue #49: Case for fully-erased generics]] (oprypkhantc, 2021). * [[https://github.com/arnaud-lb/php-src/pull/4|arnaud-lb/php-src#4: Generics experimentation]] (Le Blanc, 2024). * [[https://thephp.foundation/blog/2024/08/19/state-of-generics-and-collections/|PHP Foundation: State of Generics and Collections]] (Le Blanc, Rethans, Garfield, August 2024). * [[https://thephp.foundation/blog/2025/08/05/compile-generics/|PHP Foundation: Compile time generics]] (Garfield, Banyard, August 2025). * [[https://github.com/ircmaxell/PhpGenerics|ircmaxell/PhpGenerics]] (Ferrara, 2015). * [[https://github.com/mrsuh/php-generics|mrsuh/php-generics]] (Sukhachev, 2021). * [[https://github.com/php-fig/fig-standards/pull/169|PSR-5 draft]] (van Riel et al.). ==== Articles and talks ==== * Brent Roose, [[https://stitcher.io/blog/we-dont-need-runtime-type-checks|We don't need runtime type checks]] (stitcher.io, July 2021). * Brent Roose, generics series (stitcher.io, March 2022): [[https://stitcher.io/blog/generics-in-php-1|The basics]], [[https://stitcher.io/blog/generics-in-php-2|Generics in depth]], [[https://stitcher.io/blog/generics-in-php-3|Why we can't have generics in PHP]], [[https://stitcher.io/blog/generics-in-php-4|The case for PHP generics]]. * Chris Holland, [[https://www.phparch.com/2018/11/the-case-for-generics-in-php/|The Case for Generics in PHP]] (php[architect], November 2018). * Jeremy McPeak, [[https://laracasts.com/series/jeremys-larabits/episodes/10|Using Generics with PHP]] (Laracasts, August 2024). * SymfonyLive Paris 2023, [[https://symfony.com/blog/symfonylive-paris-2023-generics-in-php|Generics (in PHP)]]. ===== Changelog ===== * 2026-05-06: Initial draft (v0.1). * 2026-05-06: Full proposal text (v0.2). * 2026-05-06: Updated introduction example (v0.3). * 2026-05-06: Expanded "Why bound erasure?" with sections on PHP's existing type-checking model, production track record, prior art (Java, Scala, Kotlin), future runtime models, and relationship to other PHPDoc-expressed types (v0.4). * 2026-05-06: Added "Prior PHP work" section covering userland implementations (v0.5). * 2026-05-06: Reflection API: throw on absent bound/default and non-ancestor lookups (v0.6). * 2026-05-07: Arity validation (255-arg cap, link-time, runtime); three-way vote split (v0.7). * 2026-05-08: Full rewrite (v0.8). * 2026-05-10: Variance enforcement at class declaration; class-T forbidden in static context (v0.9). * 2026-05-10: Variance enforcement extended to function-, method-, closure-, and arrow-level type parameters (v0.10). * 2026-05-10: Drop type arguments on ''array'' and ''iterable''; left to a future RFC on typed collections (v0.11). * 2026-05-11: Allow forward and mutually recursive bounds within a single parameter list (v0.12). * 2026-05-11: Clarify that arity and bound checks at call sites are opt-in via turbofish (v0.13). * 2026-05-11: Diamond merge for inheritance (v0.14). * 2026-05-11: Tighten the arity cap from 255 to 127 (v0.15).