| Version | 0.7 |
| Date | 2026-07-30 |
| Author | Holly Schilling, holly.a.schilling@outlook.com |
| Status | Draft |
| Depends on | Monomorphized Generics (required); Extension Methods (for the //Extensions// section only, which is severable) |
| Implementation | https://github.com/hollyschilling/php-src/tree/generic-methods |
| Discussion thread | tbd |
| Voting thread | tbd |
The Monomorphized Generics RFC gives PHP generic classes: Vec<Bag> is a real class whose type argument is enforced at runtime by the engine's ordinary typed-parameter machinery. The single most-requested operation it does not express is the transforming method — the method whose result type depends on a type argument chosen at the call site, not at the class's construction:
class Sequence<T> {
/** Transform every element; the caller chooses the element type of the result. */
public function map<U>(Func<T, U> $f): Sequence<U> { ... }
}
$prices = $orders->map<Price>($centsToPrice);
// $prices is Sequence<Price> — a real class, runtime-enforcedThis RFC adds method-level type parameters, bound by explicit type arguments at each call site. The explicitness is not a stylistic preference; it is the load-bearing design decision, and the next section explains why.
The base RFC's position — repeated in its Future Scope — is that method-level generics are impossible in PHP as inferred generics. The argument is structural: PHP has no overloading and no compile-time call-site resolution; a call $seq->map($fn) cannot recover U from $fn because closures are untyped values at runtime and dispatch is late-bound. Go's designers reached the identical conclusion for parameterized methods.
That theorem constrains inference, not binding. If the call site names the type argument — $seq->map<Price>($fn) — there is nothing left to infer: the engine can materialize the method instantiation on first use exactly the way the base RFC materializes class instantiations. This RFC therefore requires type arguments at every generic method call. There is no inferred form, and none is planned; a future inference proposal would be a different RFC with a different (and much harder) burden of proof.
A method may declare type parameters after its name. Parameters carry the same : bounds as class-level parameters, over the same type universe including composites and generic bounds — a bound may reference the method's own parameters (maxBy<U: Comparable<U>>) and the enclosing template's (wrap<U: Box<T>>, enforced as Box<Price> on a Seq<Price> receiver):
class Sequence<T> {
public function map<U>(Func<T, U> $f): Sequence<U> { ... }
public function keyBy<K: string|Stringable>(Func<T, K> $key): Map<K, T> { ... }
}- Methods only. Free functions (
f<int>(...)) are excluded from this RFC; the call-site machinery they need is a strict subset of what methods need, so they remain available as a compatible follow-up. - A method type parameter may not shadow a class type parameter of the enclosing template (compile error): the two substitution passes must stay unambiguous.
- Method type parameters may not declare packs (
<...Ts>) in this version. - Method type parameters may not carry variance annotations (
in/outare declaration-site properties of interface contracts; a method's arguments are bound explicitly at each call site, so there is no relation for variance to describe).
Inside the method, a method-level parameter U is usable in the same positions class-level T is: parameter types, return types, new U(), U::class, composite references such as Sequence<U> in type positions and new Sequence<U>() in the body, and as a member of composite types and arguments (U|false returns, new Sequence<U|null>()) with the base RFC's folding, splicing and re-canonicalization semantics. Composites are sound here for the same reason the base RFC's deferred references are: at binding time U is a concrete argument, so substitution depth cannot grow. Closures and arrow functions declared inside the method see U as well: their signatures substitute when the closure is created by a given instantiation, and their bodies resolve U through it — two closures produced by mapper<Price>() and mapper<Order>() enforce different, correct signatures over the same compiled body.
Call sites accept both of the base RFC's spellings — plain < where it is provably unambiguous, and the explicit form ::< everywhere:
$prices = $seq->map<Price>($toPrice); // single argument: both spellings work
$prices = $seq->map::<Price>($toPrice);
$pairs = $seq->zip::<Price, Order>($other); // MULTIPLE arguments: '::<' required
$mixed = $seq->map::<int|string>($fn); // COMPOSITE arguments: '::<' required
$made = Sequence<Order>::of<Price>(...); // static calls, including
$this->helper(self::of<Price>(...)); // self:: / parent:: / static:: forms
$seq->zip::<Price, Order, Extra>($x); // Error: expects 2 type arguments, 3 given
$seq->count::<Price>(); // Error: Method Sequence<Order>::count() is not genericThe split follows from the base RFC's soundness-first lexer contract, not taste. A single bare argument before ( — $seq->map<Price>($fn) — is claimable because its comparison reading is a parse error today (</> are non-associative). A comma list or a composite before ( has a valid PHP 8 reading (f(A < B, C > (5)) is two comparisons; |/& bind looser than <), so plain < cannot claim those shapes; the explicit form — a parse error everywhere in current PHP, therefore unconditionally safe — spells them. Writing ::< at every call site is always allowed; a coding standard may prefer it for uniformity.
Nothing is silent, in either direction. Writing the plain form where ::< is required is not a quiet mis-parse: a type-argument-shaped list with a comma or union/intersection punctuation, closed by > and followed by (, sitting on a method receiver (->name, ?->name, ::name) is a compile error:
Ambiguous mix of comparison and generic syntax; write the generic method
call with '::<', or parenthesize the comparisons
```
This is deliberate: in that exact position both readings exist — the PHP 8 comparison chain and the generic call — so neither is allowed to win silently. 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; it is never silently a comparison — and anything that remains silently a comparison (variables, literals, unbalanced shapes) could never have been type arguments. Whitespace never changes the outcome: $o->m < A, B > ($x) and $o->m<A,B>($x) are the same token stream and the same error.
Generic methods respect visibility exactly as plain methods do, on both instance and static dispatch ("Call to private method V::priv() from global scope").
Type arguments at call sites must be concrete, drawn from the base RFC's full argument universe — class names (including instantiations), scalars, array, and composite (DNF) types with the same canonicalization and duplicate rules. A call site inside another generic body may not pass T/U through as an argument in this version, and type parameters may not appear as composite members.
On the first call of $seq->map<Price>(...) for a given receiver class and argument list, the engine:
- resolves the base method (
map) on the receiver;
- checks arity and bounds of the type arguments (bound violations are catchable
Errors naming the argument, the bound, and the method);
- creates a method instantiation: a function header sharing the base method's compiled body, with
U substituted into its signature — parameter types, return type, and composite types alike;
- caches it per request, keyed by the resolved base and arguments.
Subsequent calls at the same site hit the engine's ordinary inline method cache: after the first call, a generic method call costs the same as a plain method call (see Performance). Substitution composes with class-level generics with no additional machinery: by the time map<Price> is stamped on Sequence<Order>, class stamping has already substituted T — method stamping only touches U.
Error messages carry fully-substituted signatures, which is the runtime-enforcement thesis of the base RFC extended to methods:
Sequence::pick(): Argument #1 ($x) must be of type Price, Order given
Sequence::lie(): Return value must be of type Price, stdClass returned
Generic method Sequence::map() expects 1 type argument, 2 given
```
This RFC deliberately does not allow generic methods in interface declarations. An interface member is a contract, and checking that map<V>(Func<T,V>): Sequence<V> satisfies map<U>(Func<T,U>): Collection<U> requires signature comparison up to renaming of method parameters (alpha-equivalence) threaded through the engine's entire inheritance checker — the single largest cost in the design space, purchased for little practical gain.
The practical gain is had a different way. An extension method targeting an interface is a single implementation with no contract — nothing implements it, nothing overrides it, nothing needs alpha-equivalence:
interface Repository {
public function all(): array;
}
extension RepoOps on Repository $repo {
function wrapFirst<T>(): T {
return new T($repo->all()[0]);
}
}
$orders->wrapFirst<Price>(); // Price; return type enforced per instantiation
Dispatch composes with the Extension Methods RFC's activation rules unchanged: extension resolution is per-calling-file, and method instantiations are cached per resolved implementation, so two files with different active extensions cannot observe each other's bindings.
Boundary: an extension method may declare its own type parameters; it may not reference the target's (extension E on Collection<T> is future scope — it requires resolving type parameters through the runtime receiver's binding, a separate mechanism). In practice this means the transforming-method pattern types its output fully (Sequence<U>, enforced) while its input relies on the Func wrapper's own typed __invoke — which enforces per call in either case.
This section is severable: if the Extension Methods RFC does not pass, generic methods on classes stand alone.
Measured on the prototype (debug build, 3,000,000 calls, identity method):
Comparison
Ratio
Generic method call (cached) vs plain method with identical class-typed signature
0.987x
Generic method call (cached) vs plain method with object-typed signature
~3.9x
Both numbers are honest and both matter. The first is the architecture claim: after the first call per (class, arguments) pair, dispatch cost is zero — the instantiation sits in the same inline cache a plain method would. The second is what a naive benchmark will show, and it is not dispatch cost: it is the price of real per-call class-type enforcement versus a bare object check — that is, the price of the type safety itself, identical to what a hand-written Price-typed method costs. The base RFC's JIT result carries over structurally: instantiations share the template's compiled body, so the JIT compiles a generic method once.
All structural failures are catchable Errors: wrong arity, non-generic method called with type arguments, unknown method, bound violations, scalar or composite argument used where a class is required (new U() with U = int or U = A|null). Type mismatches at the boundary are ordinary TypeErrors with substituted signatures.
- No inference.
$seq->map($fn) on a generic method is an arity error, permanently in this design.
- No free functions (compatible follow-up).
- No generic methods in interface declarations (see Extensions; alpha-equivalent satisfaction checking is explicitly rejected scope).
- No access to the target's type parameters from extensions (future scope: receiver-binding provenance, which also resolves the base RFC's known generic-trait edge).
- Type arguments are not constants.
U::class works in runtime positions (it resolves through the executing instantiation); in genuinely constant expressions (const X = [T::class]) it is a compile error, because the value differs per instantiation.
The single-argument plain call form occupies dead syntax (its chained-comparison reading is a parse error today, by non-associativity), and the ::< form is a parse error everywhere in current PHP. The declaration syntax extends the base RFC's grammar in method position only.
One deliberate loud break (the ambiguity poison above): an expression whose token stream is ->name / ?->name / ::name followed by <, a bare-name list containing a comma or |/&, >, and ( — that is, comparing a property or class constant against bare constants, in a comma list or bitwise chain, immediately followed by a parenthesized expression — becomes a compile error instead of a pair of comparisons. The recovery is one character class in either direction: parenthesize the comparisons to keep the PHP 8 meaning, or write ::< to make the call. The same top-1,000-package corpus scan that cleared the base RFC's shapes (173,904 files, 113.3M tokens) found zero occurrences of this token shape either; nothing else changes meaning, silently or otherwise.
Next minor after the Monomorphized Generics RFC lands.
Instantiations are distinct classes, so cross-instantiation private access is denied: a Seq<int> method cannot touch a Seq<Price> object's private members, even from a generic factory building it. This is the same invariance position the base RFC takes for the type system, applied consistently to visibility; factories cross the boundary through constructors and public API, and the prototype's tests lock the behavior in.
Branch generic-methods (on the experimental roadmap tree). Working end to end, including everything this RFC specifies: declaration grammar; explicit-argument instance AND static call sites in both spellings (self::/parent::/static:: included; multi-argument and composite lists via ::<); composite (DNF) type arguments flowing through body references (new Sequence::<U> with a composite U stamps the canonical instantiation); instantiation-on-miss with per-request caching and ordinary visibility checks; class+method substitution composition; new U(), U::class and composite body references; closures over method parameters (signatures substituted at creation, bodies resolved through the adopted binding); bounds; extension targeting; opcache persistence of method templates in both shared memory and the file cache. Zero memory leaks under the debug allocator; 88 targeted tests plus the full engine suite pass with the feature in place, with and without opcache.
Simple yes/no, 2/3 required. Severed second vote for the Extensions section if the Extension Methods RFC is still in discussion at voting time.