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. We'll return to why that pairing is the interesting one.

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 (checking type arguments dynamically at every boundary) tax the runtime. Erased generics (arguments vanish after compilation) 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 (a concrete class per instantiation) 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, and it's worth listing the receipts, because this RFC is less an invention than an audit:

  • Typed properties and full signature enforcement (PHP 7.4) mean a monomorphized class needs no new checking machinery at all. Substitute T with int in the signatures and property types, and enforcement falls out of code that already exists and is already optimized.
  • Opcache preloading (PHP 7.4) provides exactly the ahead-of-time, whole-application view monomorphization was assumed to lack. Type arguments are statically written — there is no Sequence<$t> — so the set of instantiations in preloaded code 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. PHPStan and Psalm settled 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 that the analyzers can adopt by promoting conventions they already enforce. Go reached the same conclusion from the same starting point 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, indistinguishable at runtime from one you wrote by hand:

  • 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; even a generic factory crosses instantiations through constructors and public API. Variance annotations are future scope, and invariance is the conservative default every later relaxation stays compatible with.
  • 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. Since < can't appear in a declared class name, collisions are impossible.

That last point deserves a demonstration, because the mangling absorbs aliases and namespaces before it settles on a 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

And it hides the pragmatic escape hatch. 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.

Bounds, and T in the body

Each parameter takes at most one bound, spelled with the keyword PHP already uses for the relationship: T implements Comparable for an interface, T extends Exception for a class. Violations throw at the instantiation site, naming the argument, the bound, and the parameter. Type arguments are class names, generic instantiations (Sequence<Sequence<int>> nests fine), or the four scalars int, float, string, bool — scalars never satisfy a bound.

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

Composites work too: a template can mention itself or other generics through its own parameter, in signatures and bodies alike. NineLine leans on this constantly — Sequence<T>::appendSequence(Sequence<T> $other) rejects a wrong-typed sequence at the call boundary, and Map<V> hands back Sequence<string> from keys() and Sequence<V> from values():

public function appendSequence(Sequence<T> $other) mutating: void { /* ... */ }
public function values(): Sequence<V> { /* ... */ }

Signature types stay symbolic in the template and substitute at stamping, so diagnostics come out fully concrete (must be of type Sequence<int>, Sequence<string> given); body references like new Sequence<T>(...) or instanceof Map<V> resolve per execution against the running instantiation's arguments. The recurring discipline is that arguments stay bareSequence<Option<T>> is a compile error wherever a parameter is involved, and that's not caution but the finiteness rule: substitution can never produce a deeper generic reference than was written, so ahead-of-time stamping always terminates.

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 on it, and which needs none of this machinery. Concrete arguments also work anywhere inheritance clauses accept a name today (class IntBag implements Collection<int>), and the parameter-dependent form is what connects a generic class to its generic contract — the same bare-argument finiteness rule applying in these clauses too, which is the theorem that makes preloading's closed world closed.

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. Bounds are the check-once mechanism for callers; per-instantiation linking is PHP's existing link-time validation, extended. Under preloading, all of it runs at server start — which is to say, at deployment time, for the whole application.

Earlier drafts drew their scope line at parameter-dependent extends — a parent contributes state, constructors, and bodies, which means linking a template against a class that doesn't exist yet. The v0.10 draft crosses that line, and the mechanism is characteristically boring: the template links at declaration as an ordinary parentless class, and the parent is grafted per instantiation. So class AuditedStore<T> extends Store<T> compiles parentless; stamping AuditedStore<int> substitutes Store<T> to Store<int>, stamps it in turn, and runs ordinary inheritance against it — constructors inherit, parent:: works, signature compatibility and #[\Override] are checked with fully 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 instead.) One subtlety worth knowing: a concrete interface on such a template must be satisfied by the template's own members, because it resolves before any parent exists; parameter-dependent interfaces resolve after the graft and may lean on inherited methods.

The draft also grew variadic type-parameter packs, using PHP's variadic spelling: a template may declare one pack, binding one or more arguments, with any bound checked against every argument it captures. Fixed parameters may sit on either side of it, which is exactly the shape a typed-delegate family needs — and NineLine's Func is precisely that, a pack of argument types followed by a return type:

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. Because template bodies compile once and are shared, a pack can never stand where a single type is required; its only other use is being spread into the template's own parameter-dependent 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, adaptations accept generic references, and a class can even use Cache<int> and Cache<string> together, resolving the inevitable method collision with the ordinary insteadof/as tools.

The call site picks the type: generic methods

The v0.9 draft called method-level generics 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 actually constrains: inference, not binding. If the call site names the type, there is nothing left to infer:

struct Sequence<T> {
    public function map<U>(Func<T, U> $fn): Sequence<U> { /* ... */ }
    public function reduce<U>(Func<U, T, U> $fn, U $initial): U { /* ... */ }
}

$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: there is no inferred form.

The explicitness is the load-bearing decision, not a stylistic one, and the mechanics rhyme with class stamping. The first map<string> call for a given receiver class checks bounds, stamps a method instantiation — a function header sharing the compiled body, with U substituted through parameter types, return type, and composites like Sequence<U> — 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 class-typed 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 a hand-written class-typed method pays too.

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> requires signature comparison up to renaming, threaded through the entire inheritance checker — 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 ever needs to check satisfaction. That section is severable, standing or falling with the extension methods RFC itself.

Generic structs

You have been reading generic structs since the first code block, so it's worth stating plainly 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<V>, Func<...TArgs, TReturn>, and the two monads are all 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, and the substituted property types are enforced by the same machinery as everywhere else. 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.

There's a second-order payoff visible in NineLine's Option<T>. Because Sequence<?int> isn't expressible — nullable types can't be type arguments — Sequence<Option<int>> becomes the way to hold maybe-values in a collection, and the restriction quietly pushes you toward the better-typed shape. The drafts are converging on their own, too: the generics RFC spells its own pack example struct Func<...Tp, R>, and the structs RFC's future scope sketches anonymous tuples (int, string) as parameterized structs. The corners still unpinned live exactly where you'd guess — struct interfaces meeting mutating-colored generic contracts, and what a pack means inside a value type's copy semantics. Expect this section to grow.

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 about what an instantiation is: a class, not an object. Sequence<int> is stamped once no matter how many Sequence<int> values your request creates, and creating those costs exactly what object creation always costs.

What the one-time stamp buys is metadata — headers, signatures, property tables — never code, because opcodes are shared. The RFC now puts numbers on it: a 4-method, 2-property instantiation measures about 3.5 KB and a 12-method one about 6.5 KB — roughly 540 bytes per method, 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. The JIT compiles each template method once. Preloading stamps the transitive closure of every statically written instantiation into shared memory before the first request, so steady state pays nothing and bound violations surface at deploy.

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 one genuine break is a comparison chain of bare constants spanning a comma: f(A < B, C > $d) parses as two comparisons today and a generic reference tomorrow. Numeric and variable comparisons are structurally rejected by the bounded lookahead, so they keep meaning what they meant; C# accepted the identical trade decades ago. And one wry casualty of history: 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 is still a disciplined list, but a shrinking one — parameter-dependent extends graduated into the proposal, and generic methods graduated into their companion RFC by the explicit-arguments route. What remains deferred: nested parameter-dependent arguments (Sequence<Option<T>> written inside a template), composites inside union and intersection types, multiple and generic bounds (T implements Comparable<T>), variance, zero-length packs, and generic free functions. Inferred generic methods remain out permanently — that part of the theorem Go also proved is untouched.

Sources