PHP 9.0 doesn't exist. This chapter tours draft proposals that may change — or die — in review: eventually wrong, possibly before it ships. About this series →

Generics: The Comments Become Code

Every sizable PHP codebase contains the same class, written hundreds of times: a collection that holds one kind of thing. You write UserCollection, OrderList, IntStack — or you give up, pass an array, and put the truth in a comment. That second path grew an entire shadow type system: @template T, array<int, User>, PHPStan and Psalm enforcing in annotations what the language cannot say. The annotations are the demand curve. Parameterized types are already the single most-used feature of PHP's type system; they just live where the engine can't see them.

PHP 9.0 moves them into the language:

struct Sequence<T> implements Countable, IteratorAggregate {
    private array $items = [];

    public function push(T $item) mutating: void { $this->items[] = $item; }
    public function pop() mutating: ?T { return array_pop($this->items); }
}

$nums = new Sequence<int>();
$nums->push(4);
$nums->push("oops");
// TypeError: Sequence<int>::push(): Argument #1 ($item) must be of type int, string given

Read that error message again, because it's the whole design thesis in one line. The type argument is real at runtime — that's a genuine TypeError from the same typed-parameter machinery that checks every hand-written signature, with the substituted type in the message. And it costs essentially nothing on the hot path, because Sequence<int> shares the compiled bytecode of its template.

That example is lifted from NineLine, a demo standard library built on this whole roadmap, and the names in this chapter follow it so you can read the two side by side. Note what its Sequence<T> actually is: a struct, with mutating methods, from chapter two. Generic templates are classes, interfaces, and traits — and structs, being class-like constructs, come along for free.

The trilemma that expired

PHP has been here before — a 2016 draft RFC proposed generics and stalled on a trilemma that was, at the time, airtight. Reified generics tax the runtime by checking arguments at every boundary. Erased generics are alien to a language whose types are enforced at runtime — Sequence<int> would be a lie you can't even reflect on. And monomorphized generics were assumed to explode code size and defeat opcode caching, since PHP compiles one file at a time and can't see instantiations coming.

Every premise of that third objection has since expired:

  • Typed properties and full signature enforcement (PHP 7.4) mean a monomorphized class needs no new checking machinery at all. Substitute T with int, and enforcement falls out of code that already exists and is already optimized.
  • Opcache preloading (PHP 7.4) provides exactly the whole-application view monomorphization was assumed to lack. Type arguments are statically written — there is no Sequence<$t> — so the set of instantiations is enumerable, and gets stamped into shared memory before the first request.
  • The JIT (PHP 8.0) compounds it: instantiations share the template's opcode arrays, so the JIT compiles a generic method once and every instantiation runs the same machine code.
  • The ecosystem converged on @template, the PHP Foundation's 2024 research named collections as the driving use case, and a userland Composer plugin proved monomorphization end to end — at the cost of a build step this proposal deletes.

Monomorphization is also the rare answer that satisfies both historical camps. The runtime-types camp gets real errors with real substituted types. The tooling camp gets static syntax the analyzers can adopt by promoting conventions they already enforce — and patches for Psalm and PHPStan now track the same grammar. Go reached the same conclusion in 1.18; Hack made the same trade in a PHP-adjacent engine.

One template, stamped on demand

A declaration with type parameters is a generic template. It compiles and registers like an ordinary type, but you can't instantiate, extend, or implement it bare — new Sequence without arguments is a descriptive Error, not a mystery.

The instantiation Sequence<int> is materialized — stamped — the first time anything needs it: a new, a lookup, a link-time reference, or ahead of time under preloading. Stamping checks arity and bounds, then creates a class entry that shares the template's opcode arrays and clones only per-class metadata, with every type that mentions a parameter substituted. What comes out is an ordinary linked class:

  • Statics are per-instantiation. Counter<A>::$count and Counter<B>::$count are independent, like any two classes.
  • Identity is nominal and invariant. Sequence<int> and Sequence<string> are unrelated; Sequence<Bag> is not a subtype of Sequence<Countable>. The invariance goes all the way down to visibility — a Sequence<int> method cannot touch a Sequence<string> object's private members. Interfaces can opt into variance; classes and unannotated parameters stay invariant permanently.
  • The name is the identity. Arguments are resolved and mangled into a canonical name, which is what get_class() returns and what the class table keys on.

That last point deserves a demonstration, because the mangling absorbs aliases and namespaces before it settles on a name — and because a type argument is a good deal wider than a class name:

namespace App\Reporting;

use module BetterPractice\NineLine as NL;
use NL:>Sequence as Seq;   // alias a module member into the local file

Seq<int>::class;         // "BetterPractice\NineLine\Collections\Sequence<int>"
Seq<Seq<int>>::class;    // nested — resolved and mangled at both levels
Seq<int|string>::class;  // "…\Sequence<int|string>"
Seq<string|int>::class;  // "…\Sequence<int|string>"      — the same class
Seq<?User>::class;       // "…\Sequence<App\User|null>"   — same as Seq<User|null>
Seq<A|(B&C)>::class;     // "…\Sequence<(App\B&App\C)|App\A>"

An argument can be a class name, a nested instantiation, one of the four scalars, array, or a composite (DNF) type over any of those. Canonicalization sorts members and rejects duplicates, so every way of writing the same composite converges on one instantiation — which is the property that makes composites safe to allow at all. Enforcement is borrowed rather than built: a stamped Sequence<int|string> checks push(T $x) exactly as a hand-written int|string $x would. Invariance is untouched, because a union argument widens what one instantiation contains, not which instantiations are compatible.

Because the canonical name is an ordinary class-table key, the string form works everywhere class-strings work:

$cls = "BetterPractice\\NineLine\\Collections\\Sequence<App\\User>";
$seq = new $cls;            // stamps on first use
class_exists($cls);         // true (and now it does)
new ReflectionClass($cls);  // reflects like any class

There's deliberately no expression form — new Sequence<$t> does not exist — but DI containers and serializers, which live on dynamic class names, keep working with zero new API.

Two spellings, one grammar

Every generics proposal eventually meets the same wall: < is already a comparison operator, and the parser has to decide.

Where you write a type, plain < is the only form you need. Declaration headers, type positions and inheritance clauses are grammatically unambiguous — a parser expecting a type has nothing to decide, and no argument count or shape changes that. Where you use a type, plain < covers most sites but not all, and the explicit form ::< — the turbofish, to Rust readers — fills the gap:

$v = new Sequence::<int>();          // interchangeable — after `new`, plain `<`
$v = new Sequence<int>();            // is committed by the grammar, any shape

Pair<int, string>::make();           // fine: the `>` is followed by `::`
$seq->map<Price>($toPrice);          // fine: one bare argument before `(`

$seq->zip::<Price, Order>($other);   // REQUIRED: comma list before `(`
$seq->map::<int|string>($fn);        // REQUIRED: composite before `(`

The rule behind that table is short enough to keep in your head: at an expression site, what decides is the token after the closing >. If it's ::, any shape is claimable, because a > followed by :: can continue no expression. If it's (, only a single bare argument is, because that's the only shape whose comparison reading is already a parse error today.

So the pinch falls on method calls and almost nowhere else — a call is the thing that puts ( after the >, and that's exactly where f(A < B, C > (5)) is two perfectly ordinary comparisons. The turbofish covers those shapes because ::< is a parse error in every version of PHP, which makes it unconditionally available without claiming a single byte of syntax that means something today.

Bounds, and T in the body

A parameter may declare one bound, written with a colon and drawn from the same universe as arguments:

class Sorted<T: Comparable>              { }   // interface bound
class Guard<T: Exception>                { }   // class bound
class Map<K: string|int, V>              { }   // scalar union — NineLine's keys
class Printer<T: string|Stringable>      { }   // mixed scalar and interface
class Feed<T: Traversable&Countable>     { }   // intersection bound

There's no implements/extends distinction — the relation is whatever the bound resolves to. That sounds like tidying, but the keywords were the thing standing in the way: implements A&B has to mean something about interfaces specifically, and extends int|string means nothing at all. A colon makes no such claim, so a bound can be anything an argument can be.

Satisfaction composes in both directions. A union bound is satisfied by an argument admitted by some member — an interface member admits every implementor, an open set; a scalar member admits exactly itself, 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. Bounds canonicalize like arguments, so K: string|int and K: int|string are the same bound and diagnostics print one spelling.

Scalars in bounds are a small feature carrying real weight. Map<K: string|int, V> is NineLine's actual signature, and it's the difference between a keyed collection that models PHP's array keys and one that apologizes for them.

A bound may also reference the template's own parameters:

class Sorted<T: Comparable<T>>          { }   // F-bound — comparable to itself
class Pair<K, V: Box<K>>                { }   // constrained by a sibling parameter
class Guard<K, V: K>                    { }   // V must be a subtype of K

Those mentions stay symbolic and substitute per instantiation before checking, so the diagnostic is concrete: "Plain does not satisfy the bound Comparable<Plain> of type parameter T on Sorted".

Inside the template, T works in every type position — parameters, returns, properties, class constants, ?T, inside unions — and, more interestingly, in class expression positions:

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; }
}

new T(...), T::class, instanceof T, T::CONST, static calls — all resolve through the executing scope's argument binding, by the same mechanism that already makes new static() work. Which is precisely why the bytecode can stay shared: nothing about the method body is per-instantiation. (new T() throws when T is a scalar or a composite; T::class always works and renders the full type.)

A template can also mention itself or other generics through its parameter, at any nesting depth, and parameters may be members of composite types — which is what collection signatures actually need:

struct Map<K: string|int, V> {
    public Sequence<K>|null $keyCache = null;
    public function get(K $k): V|false { /* ... */ }
    public function lift(): Sequence<V|null> { return new Sequence<V|null>(); }
}

Signature types stay symbolic and substitute at stamping, so diagnostics come out fully concrete (must be of type Sequence<int>, Sequence<string> given). Substitution rebuilds composites per instantiation: builtin arguments fold into the union, so V = string makes V|false exactly string|false; union arguments splice member-wise; and results re-canonicalize, so new Sequence<T|null> with T = Foo and a hand-written new Sequence<Foo|null>() are one class.

One restriction survives all of this, in the one place it's load-bearing: deferred implements/extends references take bare arguments only. Those are the references stamped eagerly in a chain, and letting them grow is what would stop ahead-of-time stamping from terminating.

Contracts that carry the parameter

Generic interfaces close the loop that makes collections worth having:

interface Collection<T> {
    public function add(T $item): void;
    public function first(): ?T;
}

class Store<T> implements Collection<T> {
    public function add(T $item): void { /* ... */ }
    public function first(): ?T { /* ... */ }
}

new Store<int>() instanceof Collection<int>;   // true
function drain(Collection<int> $c) { /* accepts Store<int> */ }

Collection<T> is illustrative rather than something NineLine ships — its Sequence<T> satisfies the non-generic Countable and IteratorAggregate, which is what makes foreach and count() work and needs none of this machinery. Concrete arguments work anywhere inheritance clauses accept a name today, and the parameter-dependent form is what connects a generic class to its generic contract.

Interface satisfaction is checked per instantiation, and the RFC plants a flag on this rather than apologizing for it: a template may legitimately satisfy its contract for some arguments only, and PHP performs no compile-time checking of method bodies for non-generic code either. Under preloading it all runs at server start — which is to say, at deployment time.

Parameter-dependent extends works the same way, by a mechanism that is pleasingly boring: class AuditedStore<T> extends Store<T> compiles as an ordinary parentless class, and the parent is grafted per instantiation. Stamping AuditedStore<int> substitutes Store<T> to Store<int>, stamps it, and runs ordinary inheritance — constructors inherit, parent:: works, #[\Override] is checked with substituted types, cyclic chains are a catchable Error. (This one is genuinely class-only: structs have no extends at all, so NineLine's collections compose through traits and interfaces.)

Variadic type-parameter packs use PHP's variadic spelling. A template may declare one pack binding one or more arguments, with fixed parameters on either side — exactly the shape a typed-delegate family needs:

struct Func<...TArgs, TReturn> { /* wraps a callable, validated at construction */ }
struct Action<...TArgs>        { /* the void-returning counterpart */ }

$add = new Func<int, int, int>(fn(int $a, int $b): int => $a + $b);   // (int, int): int
$add->invoke(2, 3);                                                   // 5
new Func<int, string>(fn(int $n): int => $n);   // TypeError: return type (int) is not string

One Func template covers every arity instead of the seventeen hand-written interfaces other languages ship. A pack can never stand where a single type is required; its only other use is being spread into the template's own clauses (class Zipper<...Ts> implements Merger<...Ts>).

Generic traits fill the reuse side: use Cache<User>; flattens substituted methods and typed properties into the class, and a class can even use Cache<int> and Cache<string> together, resolving the inevitable collision with the ordinary insteadof/as tools.

Variance, and the thing everyone tries first

Interface parameters may declare variance. Classes and everything unannotated stay invariant permanently — a design position, not a deferral:

interface Source<out T> {           // covariant:     Source<Dog>  is a  Source<Animal>
    public function head(): ?T;
}
interface Sink<in T> {              // contravariant: Sink<Animal>  is a  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 hooks, typed constants; an in parameter only in input positions. Violations 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 through which a Cat enters a collection of Dogs. The way out is the split C# settled on with List<T> and IEnumerable<out T>: the mutable class stays invariant and implements variant read and write interfaces, so an API can accept Source<Animal> and every Sequence<Dog> qualifies.

Polarity composes through references to any depth, including foreign ones — append(ReadableSequence<T> $items) on a WritableSequence<in T> is legal, because T sits in a covariant slot at an input position and the composed polarity is input. Positions the compiler can decide locally are compile errors; because PHP's lazy loading makes a foreign template's variance unknowable at declaration time, those are verified at first instantiation instead, throwing a catchable Error. Enforcement stays hard either way: the subtype edge exists or you get a TypeError. And the unsound escape hatches are refused by name — no default covariance with runtime patching as in Dart, no @UnsafeVariance as in Kotlin.

The call site picks the type: generic methods

Method-level generics are a theorem rather than a preference: with no overloading and late-bound dispatch, $seq->map($fn) can never infer U from a closure, because closures are untyped values at runtime. The theorem stands — and a companion draft walks around it by noticing what it constrains: inference, not binding. If the call site names the type, there's nothing left to infer:

struct Sequence<T> {
    public function map<U>(Func<T, U> $fn): Sequence<U> { /* ... */ }
    public function keyBy<K: string|Stringable>(Func<T, K> $key): Map<K, T> { /* ... */ }
}

$labels = (new Sequence<int>([1, 2, 3]))->map<string>(
    new Func<int, string>(fn(int $n): string => "#$n"),
);
// Sequence<string> — a real type, runtime-enforced.
// ->map($fn) without the type argument is an arity Error, permanently.

Method parameters carry the same colon bounds as class-level ones, and a bound may reference the method's own parameters or the enclosing template's — wrap<U: Box<T>> on a Sequence<Price> receiver is enforced as Box<Price>.

The mechanics rhyme with class stamping. The first map<string> call for a receiver class checks bounds, stamps a method instantiation — a function header sharing the compiled body with U substituted through it — and caches it. After that the call sits in the engine's ordinary inline method cache: a cached generic method call costs the same as a plain method call, measured 0.987x against a hand-written method with the identical signature. The ~3.9x a naive benchmark shows against an object-typed method isn't dispatch cost; it's the price of the type check itself, which the hand-written version pays too.

This is where the turbofish earns its keep, and where nothing is allowed to go silent. Writing $seq->zip<Price, Order>($x) isn't a quiet mis-parse into comparisons — it's a compile error telling you to write ::< or parenthesize. In that exact position both readings genuinely exist, so neither wins by default. The doctrine in one line: at a method call site, anything that looks like type arguments either works as type arguments or is a compile error.

Two boundaries define the scope. Methods only — free functions are a compatible follow-up. And no generic methods in interface declarations: checking that map<V> satisfies map<U> needs signature comparison up to renaming, threaded through the entire inheritance checker, which is the most expensive machinery in the design space bought for little. The practical gain arrives through extension methods instead: an extension method is a single implementation with no contract, so extension RepoOps on Repository can declare wrapFirst<T>(): T and nothing needs to check satisfaction.

Generic structs

You've been reading generic structs since the first code block, so it's worth stating what that composition buys. The structs RFC and the generics RFC are separate drafts on separate tracks, and neither formally specifies the other — but a struct is deliberately "a class-like construct," templates are classes with holes in their types, and nothing in the two designs collides. NineLine takes that bet on every type it ships: Sequence<T>, Map<K: string|int, V>, Func<...TArgs, TReturn>, and both monads are generic structs.

Option<int>::some(41)->map(fn($n) => $n + 1)->getOr(0);   // 42
Result<int, string>::err("bad")->unwrapOr(0);            // 0

Everything each feature promises survives the meeting. Option<int> stamps like any instantiation and copies like any value; a bound on T is checked once at stamping. The two guarantees compose into something neither delivers alone: a Sequence<Money> holds typed values that cannot alias their way out of the collection — the container relationship checked by generics, the value relationship guaranteed by structs. That is the closest PHP has ever come to a collection type meaning exactly what it says.

Composite arguments did cost Option one of its arguments, though. Sequence<?int> used to be inexpressible, which quietly pushed you toward Sequence<Option<int>>; now ?int canonicalizes to int|null and the collection holds it directly. Option<T> has to earn its place the honest way now — as a value you can map over rather than a null you must remember to check. It does, but that's a library's argument to make, not the type system's.

The drafts are converging on their own, too: the structs RFC's future scope sketches anonymous tuples (int, string) as parameterized structs. The corners still unpinned live where you'd guess — struct interfaces meeting mutating-colored generic contracts, and what a pack means inside a value type's copy semantics.

What it costs

Monomorphization's classic objection is bloat, so the design puts every cost at class-creation time and keeps receipts. First, be clear what an instantiation is: a class, not an object. Sequence<int> is stamped once no matter how many values your request creates.

What the one-time stamp buys is metadata — headers, signatures, property tables — never code, because opcodes are shared. A 4-method, 2-property instantiation measures about 3.5 KB and a 12-method one about 6.5 KB, versus ~6 KB and ~10.5 KB for hand-written classes of the same shape. An instantiation costs about 60% of writing the equivalent class by hand, paid once per process — or once in shared memory under preloading — and nothing per object. So the honest comparison isn't "generics versus nothing"; it's "generics versus the UserCollection you were going to write anyway," and the generic is smaller.

Speed tells the same story: a generic Sequence<int> versus a hand-written IntSequence runs 1.6% slower interpreted and 0.3% slower under the tracing JIT — the second figure is noise. Runtime stamping, for instantiations reached only through dynamic strings, costs single-digit microseconds once per process.

The edges

The syntax is almost entirely dead space in today's PHP — every type position and declaration site is currently a parse error, and no new words are reserved (T remains a fine class name outside a template). The lookahead that claims a lone < is soundness-first: it takes only shapes that are parse errors in generics-free PHP, and anything with an expressible-today reading is structurally declined. Comma lists before (, shift expressions like X < B >> C, anything containing | or & — all keep their PHP 8 meaning, with a regression suite pinning it. Giving up those shapes is what the turbofish is for.

One deliberate break remains, and it's narrower than it sounds. After new NAME and instanceof NAME, a plain < is committed to a type-argument list — but only on the parenless new form, since once ( follows the class name the reference has already reduced. So new Foo() < CONST is untouched; what breaks is exactly new Foo < CONST and $x instanceof Foo < CONST, both of which become loud parse errors with one-character recoveries. A token-stream scan of the top 1,000 Composer packages — 173,904 files, 113.3 million tokens — found zero occurrences of either shape. Not "rare": absent.

One wry casualty of history survives every draft: list is reserved, so the canonical collections type can't be named List<T>. The RFC works around it with Vec; NineLine picks Sequence, which is what this chapter uses throughout.

Future scope keeps shrinking — composite arguments, generic bounds, nested parameter references and variance all graduated into the proposal. What remains deferred: composite members inside deferred inheritance references, preload stamping of composite-argument instantiations, zero-length packs, generic free functions, and type aliases, that last a natural companion once you can write unions wide enough to want a name. Inferred generic methods remain out permanently; that part of the theorem Go also proved is untouched.

Sources