| Version | 0.16 |
| Date | 2026-07-30 |
| Author | Holly Schilling, holly.a.schilling@outlook.com |
| Status | Draft |
| Implementation | https://github.com/hollyschilling/php-src/tree/generics |
| Discussion thread | tbd |
| Voting thread | tbd |
Every sizable PHP codebase contains the same class, written hundreds of times: a collection that holds one kind of thing. We write UserCollection, OrderList, IntStack — or we give up and pass arrays with a docblock, and every static-analysis tool in the ecosystem has grown a shadow type system (@template T, array<int, User>) to compensate for what the language cannot say. The annotations prove the demand: parameterized types are already the single most-used feature of PHP's type system — they just live in comments.
This RFC proposes generic classes, interfaces and traits with runtime-enforced type arguments:
class Vec<T: Countable> {
private array $items = [];
public function push(T $item): void { $this->items[] = $item; }
public function pop(): ?T { return array_pop($this->items); }
}
$v = new Vec<Bag>();
$v->push(new Bag());
$v->push(new DateTime());
// TypeError: Vec<Bag>::push(): Argument #1 ($item) must be of type Bag, DateTime givenThe error message above is the design thesis in one line: the type argument is real at runtime — enforced by the same typed-parameter and typed-property machinery as any hand-written class — at zero cost on the hot path, because Vec<Bag> shares the compiled bytecode of its template.
Generics for PHP have been considered and set aside before, most visibly around the 2016 draft RFC. The objection that stuck was a trilemma: reified generics (checking type arguments dynamically at every boundary) tax the runtime; erased generics (type arguments vanish after compilation) are alien to a language whose type system is enforced at runtime and would make Vec<int> a lie; monomorphized generics (a concrete class per instantiation) were assumed to explode code size and defeat opcode caching, since PHP compiles one file at a time and cannot see instantiations ahead of time.
That analysis was correct when it was formed. Its premises have since expired, one by one:
- Typed properties and full parameter/return enforcement (PHP 7.4, 2019) mean a monomorphized class needs no new checking machinery at all: substituting
TwithBagin signatures and property types buys runtime enforcement from code that already exists and is already optimized. - Opcache preloading (PHP 7.4, 2019) gives the engine exactly the ahead-of-time, whole-application view that monomorphization was assumed to lack. Because type arguments are statically written (there is no
Vec<$t>), the set of instantiations in preloaded code is enumerable — and can be stamped into shared memory before the first request. - The JIT (PHP 8.0, 2020) compounds the design: instantiations share their template's opcode arrays, so the JIT compiles a generic method once and every instantiation executes the same machine code.
- The ecosystem converged (2019–2024) on
@templategenerics in PHPStan and Psalm, and the PHP Foundation's 2024 research surveyed the design space and identified collections as the driving use case. Userland monomorphization (mrsuh/php-generics, 2021) demonstrated the approach end to end, at the cost of a build step this RFC removes.
Monomorphization is also the approach that satisfies both historical camps at once. The runtime-types camp gets real TypeErrors with the substituted type in the message. The tooling camp gets static syntax that PHPStan and Psalm can adopt by promoting the conventions they already enforce. Go reached the same conclusion from the same starting point with 1.18's stenciling (2022); Hack's reified generics are the same trade in a PHP-adjacent engine.
The implementation accompanying this RFC is complete for everything proposed below: all suites pass with opcache off, on, and with preloading, and the measured overhead versus a hand-written class is 1.6% interpreted and 0.3% under the JIT (see Performance).
Classes, interfaces and traits may declare type parameters after their name:
class Vec<T> { ... }
class Pair<K, V> { ... }
interface Collection<T> { ... }
trait Cache<T> { ... }A declaration with type parameters is a generic template. Templates compile and register like ordinary types, but cannot themselves be instantiated, extended, implemented, or used as a trait without type arguments; each such misuse produces a descriptive Error (e.g. "Cannot instantiate generic class Vec without type arguments").
Type parameter names are case-insensitive identifiers, must be unique within the declaration, and may not be built-in type names. By convention they are short and uppercase (T, K, V), but this is not enforced.
Each parameter may declare one bound, written : followed by a type drawn from the same universe as type arguments — a class or interface name (including a concrete instantiation), a scalar, array, or a composite (DNF) over those members:
class Sorted<T: Comparable> { ... } // interface bound
class Guard<T: Exception> { ... } // class bound
class Map<K: int|string, V> { ... } // scalar-union bound (map keys)
class Printer<T: string|Stringable> { ... } // mixed scalar/interface union
class Feed<T: Traversable&Countable> { ... } // intersection boundThere is no declared implements/extends distinction — the relation is whatever the bound resolves to. Satisfaction is checked when an instantiation is created (see Instantiation) and composes both ways: a union bound is satisfied by an argument admitted by some member (an interface member admits every implementor — an open set; scalar members admit exactly themselves — a closed enumeration); an intersection bound requires all parts. On the argument side, a union argument satisfies only if every member does, an intersection argument through any of its parts, and builtin members satisfy only builtin bound members. Bounds are canonicalized exactly like arguments (sorted, deduplicated), so diagnostics print one spelling.
Generic bounds. A bound may reference the declaring template's own parameters — the classic F-bound, sibling-parameter constraints, and composite forms:
class Sorted<T: Comparable<T>> { ... } // F-bound
class Pair<K, V: Box<K>> { ... } // sibling parameter
class Guard<K, V: K> { ... } // V must be a subtype of K
class Flex<T: Comparable<T>|Stringable> { ... } // composite with a parameter memberParameter mentions stay symbolic in the stored bound and substitute per instantiation before checking, so diagnostics are concrete: "Plain does not satisfy the bound Comparable of type parameter T on Sorted". Satisfaction composes with variance — with Comparable<in C>, an implementor of Comparable<Animal> satisfies a substituted Comparable<Cat> bound through the ordinary subtype edge. Packs may not appear in bounds; pathological mutually-recursive bounds resolve to the standard catchable cycle Error.
Inside the template, a type parameter is usable:
- in any type position: parameter, return, property and class-constant types, including nullable (
?T) and inside union types; - in class expression positions:
new T(...),T::class,instanceof T,T::CONST,T::method(),T::$prop.
class Registry<T: Exception> {
public function make(string $msg): T { return new T($msg); }
public function name(): string { return T::class; }
public function check(object $o): bool { return $o instanceof T; }
}A bare type parameter shadows any class of the same name within the template body; a qualified reference that resolves onto a parameter name is a compile-time error. Expression uses resolve through the executing scope's type-argument binding, so the template's bytecode remains fully shared (the mechanism generalizes new self()/new static()). Using a scalar or composite argument where a class is required (new T() with T = int or T = A|B) throws at runtime; T::class always works and renders the full type.
A type argument is a statically written type. The argument universe is:
- a class/interface name — which may itself be a generic instantiation (
Vec<Box<int>>); - the scalar types
int,float,string,bool; array;- a composite (DNF) type over those members: unions (
int|string), intersections (A&B), unions of parenthesized intersections (A|(B&C)), and the nullable shorthand?Foo(canonicalized toFoo|null).nullis admissible only as a union member, never standalone. Each member must itself be a valid bare argument, which is what keepsmixed,callableand the other built-ins out — they were never in the universe, so composites cannot smuggle them in.
There is no runtime-expression form — new Vec<$t> does not exist; the dynamic escape hatch is the plain-string class name (below).
Every generic reference is resolved (namespace prefixes and use aliases applied, scalar spellings canonicalized, whitespace removed) and then mangled into the instantiation's canonical name. For composite arguments, canonicalization also sorts members case-insensitively and rejects duplicates ("Duplicate type A is redundant"), so the spelling is an identity — every way of writing the same composite converges on one instantiation:
namespace App;
use Countable as Cnt;
Vec<Cnt>::class; // "App\Vec<Countable>"
Vec<int>::class; // "App\Vec<int>"
Vec<Vec<int>>::class; // "App\Vec<App\Vec<int>>"
Vec<int|string>::class; // "App\Vec<int|string>"
Vec<string|int>::class; // "App\Vec<int|string>" — same class
Vec<?User>::class; // "App\Vec<App\User|null>" — same class as Vec<User|null>
Vec<A|(B&C)>::class; // "App\Vec<(App\B&App\C)|App\A>" — sort is bytewise,
// parenthesized intersections order firstEnforcement of composite arguments is the engine's own union/intersection machinery: a stamped Vec<int|string> checks push(T $x) exactly as a hand-written int|string $x would, and Vec<B&C> exactly as B&C. Invariance applies unchanged — Vec<int|string> is unrelated to Vec<int>; a union argument widens what one instantiation contains, not which instantiations are compatible.
The canonical name is the class identity: it is what get_class() returns, what error messages print, and (lowercased, like every class name) the class-table key. Because <, , and > cannot appear in declared class names, mangled names cannot collide with user classes. X<...>::class is a pure compile-time string and loads nothing.
Since the canonical name is an ordinary class-table key, the string form works everywhere class-strings work — new $name, class_exists(), ReflectionClass — which is the deliberate escape hatch for DI containers and serializers:
$cls = "App\\Vec<App\\User>";
$vec = new $cls; // instantiates (stamping on first use, below)Generic references are accepted in every type position (parameter, return, property, class-constant types), in new, instanceof, catch, static-member access, and ::class. Nested arguments are fully supported; adjacent closes (Vec<Vec<int>>, and deeper) are consumed by the grammar itself — no lexer state is involved.
function tally(Vec<int> $v): int { ... }
class Repo { public ?Vec<User> $cache = null; }
$v instanceof Vec<int>;
try { ... } catch (Failure<Payment> $e) { ... }Two spellings, one grammar. Plain < is the canonical form and works at every site above. The explicit form ::< ("turbofish") is additionally accepted wherever an argument list appears in expression context — new Vec::<int>(), Pair::<A, B>::make() — and is never required at class-level sites; it exists because ::< is a parse error in every version of PHP, so it is available unconditionally where the short form would be ambiguous (composite arguments at method call sites — see the companion Generic Methods RFC). Use < unless the compiler makes you say ::<.
How the short form stays safe is a per-context guarantee, not a heuristic gamble:
- Type positions and declaration headers are grammatically unambiguous; the parser takes
<directly. - After
newandinstanceof,<following a class name is committed to a type-argument list by the grammar (see Backward Incompatible Changes for the one reading this sacrifices). Multi-argument and composite lists parse there with no lexer involvement. - Everywhere else a bounded lookahead claims
<only for token shapes that are provably parse errors in generics-free PHP: a type-argument-shaped region whose>is directly followed by::(Vec<K, V>::make(),Vec<int|string>::class— a>followed by::can continue no expression), or a bare single-argument shape followed by((protected by the non-associativity of comparison operators). Everything else stays a comparison.
Throughout this RFC, instantiation means a class, not an object: the concrete type produced for one combination of type arguments. Vec<int> is one instantiation no matter how many Vec<int> objects exist; creating objects of it costs exactly what object creation always costs.
An instantiation such as Vec<Bag> is materialized ("stamped") the first time it is needed — on new, on a class-table lookup of its canonical name, at link time when something inherits from it, or ahead of time under preloading. Stamping:
- locates the template (autoloading it if necessary),
- checks argument arity and bounds (loading argument classes only when a bound requires it),
- creates a new class entry that shares the template's opcode arrays and clones only the per-class metadata — method headers rebound to the new class, and every type mentioning a parameter substituted with the argument,
- registers it under its canonical name.
The result is an ordinary linked class, indistinguishable from a hand-written one at runtime:
- Enforcement is the engine's existing typed-parameter/property/constant machinery operating on substituted types.
?TwithT = intis exactly?int. - Statics are per-instantiation:
Counter<A>::$countandCounter<B>::$countare independent, like any two classes. - Identity is nominal and invariant by construction:
Vec<A>andVec<B>are unrelated classes;Vec<Bag>is not a subtype ofVec<Countable>. This extends to visibility: aVec<A>method cannot access aVec<B>object's private members — factories cross instantiations through constructors and public API. (Interfaces may opt into variance — see Variance — which adds subtype edges between instantiations without weakening any of this: classes and unannotated parameters are invariant permanently.) - Unbounded arguments stay lazy, matching PHP's lazy class model:
new Vec<MissingClass>()succeeds, and enforcement fails naturally at first use exactly as a hand-writtenMissingClasstype hint would.
Bound violations, arity mismatches, and malformed dynamic names throw Error at the instantiation site:
new Sorted<stdClass>();
// Error: stdClass does not satisfy the bound Comparable of type parameter T on SortedGeneric interfaces are declared like generic classes, and participate in three ways.
Concrete arguments may be used anywhere today's inheritance clauses accept a class name:
class IntBag implements Collection<int> { ... }
class IntList extends Vec<int> { ... }
interface IntCollection extends Collection<int> { ... }The referenced instantiation is stamped at link time; variance checks run against the substituted signatures (Wrong::add(string $item) vs Collection<int>::add(int $item)), and instanceof, type hints and interface constants behave normally.
Parameter-dependent implements connects a generic class to its generic contract:
interface Collection<T> {
public function add(T $item): void;
public function first(): ?T;
}
class Vec<T> implements Collection<T> {
public function add(T $item): void { ... }
public function first(): ?T { ... }
}
new Vec<int>() instanceof Collection<int>; // true
function drain(Collection<int> $c) { ... } // accepts Vec<int>When Vec<int> is stamped, its arguments are substituted into the deferred reference, Collection<int> is stamped in turn, and the interface edges (including the interface's own transitive interfaces) are added with the ordinary constant-inheritance and method-satisfaction machinery. The same works for interface Sorted<T> extends Collection<T>.
In such references, each argument must be a bare type parameter or a concrete type — implements Collection<T> and implements MapEntry<K, V> and implements Feed<T, int> are allowed; implements Collection<Vec<T>> is not (Future Scope). This restriction is what keeps the instantiation universe finite: substitution can never produce a deeper generic reference than was written, so ahead-of-time stamping terminates without recursion limits. Cyclic interface references are detected and produce a catchable Error.
Interface satisfaction is checked per instantiation. This is a deliberate design position: a template may legitimately satisfy its contract for some arguments only —
class IntishVec<T> implements Collection<T> {
public function add(int $item): void {} // satisfies Collection<T> only when T = int
}
new IntishVec<int>(); // links
new IntishVec<string>(); // Fatal error: Declaration of IntishVec<string>::add(int $item): void
// must be compatible with Collection<string>::add(string $item): void— and under preloading these checks run at server start, i.e. at deployment time, for the whole closed world. PHP performs no compile-time checking of method bodies for non-generic code either; bounds are the check-once mechanism for callers, and per-instantiation linking is the natural extension of PHP's existing link-time validation.
Parameter-dependent extends (class MyVec<T> extends Vec<T>) is part of this proposal. The template links at declaration as an ordinary parentless class — traits flatten and concrete interfaces resolve exactly as for any class — and the parent instantiation is grafted when each MyVec<Bag> is stamped: Vec<T> substitutes to Vec<Bag>, is stamped in turn, and ordinary inheritance runs against it. Every per-instantiation guarantee follows from using the ordinary machinery: constructors inherit, parent:: works, signature compatibility is checked with fully substituted types (Declaration of WC<string>::add(int) must be compatible with WB<string>::add(string)), abstract satisfaction and #[\Override] are verified per instantiation, and cyclic parent chains produce a catchable Error. The same bare-argument restriction applies as for interfaces. One consequence to note: a concrete (non-parameter-dependent) interface on such a template must be satisfied by the template's own members, since it resolves before any parent exists; parameter-dependent interfaces resolve after the graft and may rely on inherited members.
A template may use its own parameters inside generic references, in type positions and in bodies alike:
class C<T> {
public ?Vec<T> $items;
public function f(C<T> $p): C<T> { return new C<T>([...]); }
public function is(object $o): bool { return $o instanceof Vec<T>; }
public function tag(): string { return T::class; } // also inside array literals
}Signature, property and constant types written as C<T> stay symbolic in the template and substitute when each instantiation is stamped, so enforcement produces fully concrete diagnostics (must be of type C<Bag>, C<int> given). Body references (new C<T>, instanceof Vec<T>, Vec<T>::make(), Vec<T>::CONST) resolve per execution against the running instantiation's arguments. Parameters may sit at any nesting depth inside such references — Pair<Box<T>, int> works in type positions and bodies alike — and substitution composes: with T = int|string, a body's new Vec<T> stamps Vec<int|string>.
Parameters may also be members of composite types and composite arguments — the signatures collections actually need:
class Map<K, V> {
public Vec<K>|null $keyCache = null;
public function get(K $k): V|false { ... }
public function pick(V|Fallback $x): void { ... }
public function lift(): Vec<V|null> { return new Vec<V|null>(); }
}Substitution rebuilds the composite per instantiation: builtin arguments fold into the union (V = string makes V|false exactly string|false), union arguments splice member-wise, intersection arguments nest, and members deduplicate afterward (T|Foo with T = Foo collapses to Foo). Substituted composite arguments re-canonicalize, so new Vec<T|null> with T = Foo and a directly-written new Vec<Foo|null>() are one class. The only argument-dependent shape rule — a builtin or union argument may not land inside an intersection — is validated against every declared type before any clone is built, so a failing instantiation throws a clean Error and leaves nothing behind: "Cannot stamp Meet: type argument int for parameter T cannot be used inside an intersection type".
One restriction remains, with a compatible upgrade path: deferred implements/extends references keep bare arguments only (eagerly stamped substitution chains must not grow). T::class is not a constant — using it in a constant expression (const X = [T::class]) is a compile error, because its value differs per instantiation.
A template may declare at most one pack, written with PHP's variadic spelling, at any position in the parameter list; it binds one or more arguments (never zero in this version):
class Zip<...Ts> { } // Ts = all arguments
struct Func<...Tp, R> { } // params bind left-to-right before the
// pack, right-to-left after it
interface Merger<A, B> { }
class Zipper<...Ts> implements Merger<...Ts> { } // spread into deferred referencesZip<int, string, Foo> binds Ts = [int, string, Foo]; Func<int, string, bool> binds Tp = [int, string], R = bool — the shape a future Func-style delegate family needs, without declaring seventeen arities. A pack may carry a bound, checked against every argument it binds. Because template bodies compile once and are shared by all instantiations, a pack cannot appear where a single type is required: its only use sites are spreads into the template's own parameter-dependent implements/extends references, plus Reflection. Every misuse — a pack as a property or return type, new Ts, a bare Ts in a deferred reference, spreading anything that is not the declaring template's own pack — is a compile error. Arity errors adapt: "Generic class Func expects at least 2 type arguments, 1 given".
Traits may be generic; use supplies concrete arguments and each use site is independent:
trait Cache<T> {
private ?T $cached = null;
public function remember(T $value): T { return $this->cached = $value; }
}
class UserRepo { use Cache<User>; }
class IntRepo { use Cache<int>; }The stamped trait's substituted methods and properties are flattened into the using class by the ordinary trait machinery, so errors are scoped to the using class (UserRepo::remember(): ... must be of type User), typed properties included. Trait adaptations (insteadof / as) accept generic references. Bare use Cache; of a generic trait errors at link time. One limitation in this version: new T() inside a generic trait method throws at runtime (the flattened method executes in the using class's scope, which carries no binding); type positions in traits are unaffected.
A class may use the same generic trait at two argument sets, but since PHP has no method overloading, Cache<int>::remember(int) and Cache<string>::remember(string) cannot coexist under one name: the ordinary trait collision error is raised, and — like any trait collision — it is resolved per method with insteadof/as:
class Both {
use Cache<int>, Cache<string> {
Cache<int>::remember insteadof Cache<string>;
Cache<string>::remember as rememberString;
}
}Methods whose signatures do not mention a type parameter are identical across instantiations and coexist without conflict. Trait state cannot be doubled: a same-named property whose type substitutes differently fails the standard trait property-compatibility check, so stateful generic traits are effectively single-use per class.
Instantiations reflect as ordinary classes — substituted signatures, resolved interfaces, working newInstance() — and new ReflectionClass("Vec<Bag>") stamps on demand. The generic structure itself is exposed with six additions to ReflectionClass:
isGenericTemplate(): bool/isGenericInstantiation(): boolgetGenericTypeParameters(): array—[{name, boundKind: "implements"|"extends"|null, bound: ?string, variadic: bool}]; instantiations delegate to their templategetGenericTypeArguments(): array— argument type names, e.g.["int", "App\\User"]getGenericTemplate(): ?ReflectionClassgetGenericInterfaceNames(): array— a template's parameter-dependent contracts, unsubstituted (e.g.["App\\Collection<T>"])getGenericParentName(): ?string— a template's parameter-dependent parent, unsubstituted (e.g."Vec<T>"), or null
isInstantiable() returns false for templates. On templates, parameter and property types reflect symbolically (T, ?T), which is what documentation tooling should see.
Monomorphization's costs are placed at class-creation time so that steady-state execution pays nothing. Three properties deliver this:
- Shared bytecode. An instantiation's methods reference the template's opcode and literal arrays; only headers, signatures and property/constant metadata are per-instantiation — metadata, not code, so the classic "template bloat" objection does not apply. Measured (debug build): a 4-method/2-property instantiation costs ~3.5 KB and a 12-method one ~6.5 KB (~540 bytes per method), versus ~6 KB and ~10.5 KB for hand-written classes of the same shape and ~1 KB for an empty class — i.e. each instantiation costs about 60% of writing the equivalent class by hand, once per process (or once in shared memory under preloading), and nothing per object.
- Opcache. Templates persist in shared memory like any class. Because opcodes are shared, the JIT compiles a template method once and every instantiation executes that machine code.
- Preloading. After the preload script links, the engine computes the transitive closure of every instantiation reachable from preloaded code — type positions and class references alike — and stamps them into shared memory before the first request: zero per-request stamping, cross-worker sharing, and deployment-time surfacing of bound and satisfaction errors. Instantiations reachable only through dynamically built strings still stamp lazily at runtime.
Measured on the reference implementation (debug build; 300k push/pop iterations, best of three — ratios are the meaningful figures):
| Configuration | Hand-written IntVec |
Generic Vec<int> |
Ratio |
|---|---|---|---|
| Interpreted, no opcache | 88.4 ms | 89.8 ms | 1.016 |
| Opcache | 87.4 ms | 88.8 ms | 1.016 |
| Opcache + JIT (tracing) | 17.9 ms | 17.9 ms | 1.003 |
Runtime stamping, for code outside preload, costs roughly 1–5µs per instantiation once per process (measured: first-touch of 200 distinct instantiations, 1.02 ms runtime-stamped vs 0.07 ms preloaded).
The claimed syntax is dead space today, and this is now provable per context rather than argued per example:
- All type positions (
Vec<int> $x), declaration sites (class Vec<T>), and inheritance clauses are parse errors in current PHP. - The explicit form
::<is a parse error everywhere in current PHP — nothing may follow::with<— so the turbofish token cannot change the meaning of any existing program, in any context. - The lookahead that claims a lone
<is soundness-first: it claims only token shapes that are parse errors in generics-free PHP. A>directly followed by::can continue no expression, and a bare no-comma shape before(is a chained-comparison parse error because</>are non-associative. Shapes with an expressible-today reading — comma lists before(such asf(A < B, C > (5)), shift expressions such asX < B >> C, and any shape containing|/&before((valid comparisons by precedence) — are structurally declined and keep their PHP 8 meaning wherever no generic reading exists. Earlier drafts of this work claimed some of those shapes; this proposal claims none of them, and the regression suite pins the PHP 8 behavior. (On method receivers, where a generic call reading also exists, such shapes are instead rejected with a loud compile error rather than silently keeping either meaning — see the companion Generic Methods RFC.) - No new reserved words.
Tetc. remain valid class names outside templates; inside a template a parameter shadows same-named classes (with qualified references to the shadowed class rejected at compile time).
The one deliberate break: after new NAME and NAME following instanceof, a plain < is committed to a type-argument list by the grammar. The sacrificed reading is narrower than it first appears: the commit engages only on the parenless new form, because once ( follows the class name the reference has already reduced and a later < is an ordinary comparison — new Foo() < CONST keeps its PHP 8 meaning unchanged. What breaks is exactly new Foo < CONST (no constructor parentheses) and $x instanceof Foo < CONST, both of which become loud parse errors rather than silent reinterpretations, and both of which have one-character-class recoveries: wrap the expression ((new Foo) < CONST, ($x instanceof Foo) < CONST) or, for new, simply write the constructor parentheses. A token-stream scan of the top 1,000 Composer packages (173,904 PHP files, 113.3M tokens, latest stable releases) found zero occurrences of either shape — parenless new IDENTIFIER < and instanceof IDENTIFIER < do not appear in the surveyed ecosystem at all. The scan script and methodology will be attached to the discussion thread for independent reruns.
One naming note: list is a reserved word, so the canonical collections example cannot be named List<T>. This RFC's examples use Vec; freeing soft-reserved names is out of scope.
- Bare template misuse (
new Vec,extends Vec,implements Collection,use Cache;) —Error/ link-time error naming the missing arguments. - Arity mismatch — "Generic class Vec expects 1 type argument, 2 given".
- Bound violation —
Errorat instantiation naming argument, bound and parameter, both rendered as full types ("A|Plain does not satisfy the bound Marked of type parameter T on Keep"). - Malformed dynamic name (
new "Vec<not valid>") —Error. - Parameter used as a type argument outside the allowed positions; qualified reference to a shadowed name; non-admissible built-in as argument (
mixed, standalonenull, ...) — compile-time errors. - Composite-argument violations — duplicate members ("Duplicate type A is redundant"), built-ins as intersection members ("Type int cannot be part of an intersection type"), a type parameter as a composite member (deferred; "...must be concrete in this version") — compile-time errors.
- Scalar or composite argument used as a class (
new T()withT = intorT = A|null) —Errorat the use site. - Variance violations — compile errors naming the parameter, its variance and the member ("Contravariant type parameter T of Bad may not appear in an output position (return type of get)"); variance on classes or method parameters, or combined with a pack — compile errors.
- Cyclic parameter-dependent interfaces — catchable
Error. - Interface satisfaction failures — the standard fatal, with substituted signatures, at instantiation (deployment time under preload).
Interface type parameters may declare variance; classes and everything unannotated remain invariant, permanently — a design principle, not a deferral:
interface Seq<out T> { // covariant: Seq<Dog> <: Seq<Animal>
public function head(): ?T;
}
interface Sink<in T> { // contravariant: Sink<Animal> <: Sink<Dog>
public function put(T $x): void;
}Soundness is a positional discipline checked when the interface finishes compiling — never a runtime patch. An out parameter may appear only in output positions (return types, get-only hooked properties, typed constants); an in parameter only in input positions (parameter types, set-only hooks). By-reference parameters and get+set properties are invariant positions; static members sit outside the variance contract (they are never reached through a variant reference). Violations are compile errors that name the member:
Covariant type parameter T of Bad may not appear in an input position (parameter type of add)
```
That error is the answer to the first thing everyone tries: a mutable Collection<out T> is impossible under any sound variance design, because add(T $x) is an input position — the hole where a Cat enters a collection of Dogs. The ecosystem pattern (C#'s List<T>/IEnumerable<out T> split) applies directly: the mutable class stays invariant and implements variant read/write interfaces, so APIs accept Seq<Animal> and every Vec<Dog> qualifies.
Composition. References to generic templates compose polarity through the referenced template's declared variance, to any depth. Self-references: public function chunk(): Seq<Seq<T>> on a covariant Seq is an output position for T. Foreign references compose the same way — the canonical bulk-operation signature works:
interface WritableSequence<in T> {
// T sits in ReadableSequence's covariant slot at an input
// position: composed polarity is input, where 'in T' belongs.
public function append(ReadableSequence<T> $items): void;
}
An invariant foreign slot (Vec<T> where Vec declares no variance) is an invariant position, and a slot used against its direction is rejected. Deferred extends references compose at output polarity through the extended template's variance — interface SortedSequence<out T> extends ReadableSequence<T> is legal, an in parameter extending a covariant template is not — which keeps the inherited-member soundness hole closed.
When each check runs. Positions the compiler can decide locally (bare parameters and self-references) are compile errors, as above. PHP's lazy loading makes a foreign template's variance unknowable at declaration time, so positions inside foreign references are verified once at the template's first instantiation — the same moment bounds resolve, with autoloading available — throwing a catchable Error with the same member-naming message (or Cannot verify variance of Odd: class MissingTemplate (in parameter type of f) was not found when the foreign template cannot be loaded). Verification results are cached per request. Unsound escape hatches are explicitly refused: no default covariance with runtime patching (Dart), no @UnsafeVariance (Kotlin), and variance cannot combine with parameter packs.
Runtime. A variance edge is consulted only after ordinary identity checks miss, behind two flag tests: instanceof, parameter/return/property checks, and every other path through the interface machinery accept an instantiation of the same variant template whose arguments compare per parameter — covariant arguments by subtyping (composites compose: Seq<Dog|Cat> <: Seq<Animal>), contravariant reversed, invariant by canonical identity. Argument comparison reuses the bound-satisfaction machinery in a no-autoload, no-throw mode; nested instantiation arguments recurse through the same check; results are cached per request. Enforcement stays fully hard — the edge either exists or a TypeError is thrown with substituted types, exactly as for any other mismatch.
Deliberately excluded, each with a compatible upgrade path: composite members in deferred inheritance references; preload stamping of composite-argument instantiations (they are runtime-stamped in this version; the preload sweep skips them); zero-length packs (Zip<>); generic free functions; type aliases (a natural companion for naming wide unions: type JsonValue = ...). On generic methods: class-level-only-via-inference remains a theorem — with no overloading and late static binding, a method-level parameter cannot be inferred at a call site; methods with mandatory explicit type arguments ($seq->map<Price>($fn)) sidestep the theorem and are proposed in the companion Generic Methods with Explicit Type Arguments RFC, which depends on this one. Also future: promoting scalar-argument display in T::class edge cases, and a structured ReflectionGenericTypeParameter class.
Requires a 2/3 majority. Secondary votes, if discussion warrants: none currently anticipated.
Complete for everything proposed, on the generics branch: grammar (a single %expect-documented conflict — the deliberate new/instanceof commitment), the soundness-first lookahead, the turbofish token, grammar-level fused >> closes (no lexer state), template compilation, stamping engine, : bounds over the full argument universe (subtype-against-composite satisfaction), composite (DNF) arguments with canonical identity, generic interfaces/traits, parameter-dependent implements/extends, nested parameter references, type-parameter expressions, opcache persistence for both formats, preload closed-world stamping (composite-argument instantiations excluded, runtime-stamped), and the Reflection API. 99 targeted tests, including declaration-site variance with runtime edges and foreign-variance composition, plus the full Zend, language and opcache suites pass with opcache disabled and enabled (including protect_memory), and with preloading. External-parser support: patches for Psalm and PHPStan track the same grammar.
- Draft RFC: Generics (2016) — the prior proposal and discussion this RFC audits.
- PHP Foundation: State of Generics and Collections (2024).
- mrsuh/php-generics — userland monomorphization via a Composer build step (2021).
- Go 1.18 generics implementation — stenciling/monomorphization in a runtime-typed setting; also the source of the "no parameterized methods" analysis.
- Hack reified generics — runtime-visible type arguments in a PHP-derived engine.
- C# language specification, type-argument-list disambiguation — precedent for the bounded-lookahead
< rule.