Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cb0ae0f
feat(zend): add lex and grammar for generic syntax
azjezz May 6, 2026
7cbd2d2
feat(zend): add AST kinds, decl arity, and action wiring for generics
azjezz May 6, 2026
79ff820
feat(zend): implement generic scope, bound erasure, and runtime metadata
azjezz May 6, 2026
88a74fb
feat(opcache): persist generic metadata through SHM and cache
azjezz May 6, 2026
fbca907
feat(reflection): expose generic parameters and bounds via Reflection…
azjezz May 6, 2026
9898384
feat(zend, opcache, reflection): complete pre-erasure side table and …
azjezz May 6, 2026
fab8432
feat(zend): allow generic type arguments on array, iterable, self, st…
azjezz May 6, 2026
a48a94e
test(zend, reflection): add generics test suite and fix uncovered sem…
azjezz May 6, 2026
c78779f
docs: add generics in NEWS, UPGRADING, and UPGRADING.INTERNALS
azjezz May 6, 2026
9a1ec61
test(zend): cover whitespace-free bound+default with type args (>> sp…
azjezz May 6, 2026
26e4d63
feat(zend, reflection): expose direct-ancestor type arguments on Refl…
azjezz May 6, 2026
a327d49
feat(zend): reject required type parameter after an optional one
azjezz May 6, 2026
42deea9
feat(zend): resolve type-param vs type-name collisions via shadowing,…
azjezz May 6, 2026
5dde476
fix(zend): drop const-discarding qualifiers and unused grammar nonter…
azjezz May 6, 2026
4e3d5fd
fix(opcache, reflection): plug two memory leaks in generics persisten…
azjezz May 6, 2026
7305b88
feat(zend): allow turbofish on attribute declarations
azjezz May 6, 2026
93c4de9
refactor(zend): address review feedback
azjezz May 6, 2026
cf0082d
refactor(reflection): throw on absent bound/default and non-ancestor …
azjezz May 6, 2026
9539a58
test(reflection): update fixture for non-nullable generic-args returns
azjezz May 6, 2026
a26beef
feat(zend): cap generic type parameters and arguments at 255
azjezz May 7, 2026
0d15c06
feat(zend, reflection): enforce generic-arg arity at call sites and i…
azjezz May 7, 2026
87be460
feat(zend): treat missing type args on inheritance as arity 0
azjezz May 7, 2026
1b8fa04
feat(zend, opcache, reflection): bound checks and parametric substitu…
azjezz May 8, 2026
9b4ca68
fix(zend): typo
azjezz May 8, 2026
27459bc
fix(zend): allow ?T and unions containing T when T erases to mixed
azjezz May 8, 2026
2a6b06a
fix(zend): point T-aware diagnostic at T-ref erasure in intersection …
azjezz May 8, 2026
004156c
fix(zend): support scope-free covariant type checks for generic bounds
azjezz May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8 changes: 8 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ PHP NEWS
?? ??? ????, PHP 8.6.0alpha1

- Core:
. Added generics: type parameters on classes, interfaces, traits, functions,
methods, closures, and arrow functions, with optional bounds, defaults,
and variance markers; turbofish syntax (`f::<int>()`) at call sites; and
type arguments on named types (`Box<int>`, `array<K, V>`, `iterable<T>`,
`self<T>`, `static<T>`, `parent<T>`). Type parameters erase to their bound
at runtime; type arguments are discarded. Pre-erasure metadata is preserved
for Reflection so static-analysis tools can consume generics without
re-parsing source. (azjezz)
. Added first-class callable cache to share instances for the duration of the
request. (ilutov)
. It is now possible to use reference assign on WeakMap without the key
Expand Down
51 changes: 51 additions & 0 deletions UPGRADING
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,31 @@ PHP 8.6 UPGRADE NOTES
========================================

- Core:
. Added support for runtime-bound-checked generics. Classes, interfaces,
traits, functions, methods, closures, and arrow functions can now declare
type parameters with optional bounds (`T : Foo`), defaults (`T = int`),
and variance markers (`+T`, `-T`):

class Box<T : object> {
public T $value;
public function get(): T { return $this->value; }
}

function id<T>(T $x): T { return $x; }

Call sites accept turbofish type arguments (`Box::<int>::new()`,
`id::<int>(7)`); use sites accept type arguments on named types
(`Box<int>`, `array<int, string>`, `iterable<T>`, `self<T>`,
`static<T>`, `parent<T>`). Recursive bounds (`T : Comparable<T>`)
are supported. Anonymous classes cannot declare type parameters.

At runtime each type parameter is replaced by its declared bound
(or `mixed` when unbounded, or when the bound is invalid in the
target position, e.g. `callable` on a property), and type
arguments are discarded. Pre-erasure metadata is preserved on
functions, methods, and class entries and is exposed through
Reflection so that PHP-based static-analysis tools can consume
generics without re-parsing source.
. It is now possible to use reference assign on WeakMap without the key
needing to be present beforehand.

Expand Down Expand Up @@ -232,6 +257,23 @@ PHP 8.6 UPGRADE NOTES
RFC: https://wiki.php.net/rfc/isreadable-iswriteable
. Added ReflectionParameter::getDocComment().
RFC: https://wiki.php.net/rfc/parameter-doccomments
. Added ReflectionFunctionAbstract::isGeneric() and
ReflectionFunctionAbstract::getGenericParameters() (covers
ReflectionFunction, ReflectionMethod, closures, and arrow functions).
. Added ReflectionClass::isGeneric() and
ReflectionClass::getGenericParameters().
. Added ReflectionClass::getGenericArgumentsForParentClass(),
ReflectionClass::getGenericArgumentsForParentInterface(string $name),
and ReflectionClass::getGenericArgumentsForUsedTrait(string $name) for
inspecting the type arguments supplied at a class's own extends /
implements / use sites. Returns null when no type arguments were
specified for that ancestor at this class's clause site (consumers
enumerate ancestors via the existing getParentClass() / getInterfaces()
/ getTraits() APIs).
. Added ReflectionNamedType::hasGenericArguments() and
ReflectionNamedType::getGenericArguments(). The arguments are returned
as ReflectionType instances in source order (pre-erasure form);
ReflectionNamedType::getName() continues to return the erased name.

- Intl:
. `grapheme_strrev()` returns strrev for grapheme cluster unit.
Expand All @@ -258,6 +300,15 @@ PHP 8.6 UPGRADE NOTES
. Openssl\Session
RFC: https://wiki.php.net/rfc/tls_session_resumption

- Reflection:
. ReflectionGenericTypeParameter (final, instances obtained via
ReflectionClass::getGenericParameters() and
ReflectionFunctionAbstract::getGenericParameters()).
. ReflectionTypeParameterReference (extends ReflectionType, appears only
inside pre-erasure type expressions: bounds, defaults, and the elements
of ReflectionNamedType::getGenericArguments()).
. enum ReflectionGenericVariance { Invariant; Covariant; Contravariant }.

- Standard:
. enum SortDirection
RFC: https://wiki.php.net/rfc/sort_direction_enum
Expand Down
48 changes: 48 additions & 0 deletions UPGRADING.INTERNALS
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,54 @@ PHP 8.6 INTERNALS UPGRADE NOTES
zend_fci_consumed_arg(), which allows moving a selected callback argument
instead of copying it in zend_call_function(). Currently only a single
consumed argument is supported.
. Added support for runtime-bound-checked generic type parameters. The
main additions:
. New types in zend_compile.h: zend_generic_parameter,
zend_generic_parameter_list, zend_generic_type_table, and
zend_generic_scope_entry. Allocate / destroy via
zend_generic_parameter_list_alloc(),
zend_generic_parameter_list_destroy(),
zend_generic_type_table_alloc(), and
zend_generic_type_table_destroy().
. zend_op_array and zend_class_entry both gained an optional
`generic_parameters` (declared parameter list) and an optional
`generic_types` side table holding the pre-erasure forms of
return types, parameter types, property types, class-constant
types, the extends type, implements list, and trait-use list.
The runtime arg_info / property / class-constant slots continue
to hold only the erased form.
. New AST kinds: ZEND_AST_GENERIC_TYPE_PARAMETER_LIST,
ZEND_AST_GENERIC_TYPE_PARAMETER, ZEND_AST_GENERIC_NAMED_TYPE,
ZEND_AST_GENERIC_TYPE_ARGUMENT_LIST, ZEND_AST_TURBOFISH.
. zend_ast_decl::child[] grew from 5 to 6 entries; the new slot
carries an optional generic-parameter-list AST.
. The child-count groups of ZEND_AST_CALL, ZEND_AST_NEW,
ZEND_AST_METHOD_CALL, ZEND_AST_NULLSAFE_METHOD_CALL, and
ZEND_AST_STATIC_CALL each gained one optional child holding the
call-site turbofish type-argument list. Code that walks these
nodes by hard-coded child count must be updated.
. zend_ast_export handles the new generic AST kinds.
. Two new bits on zend_type's type_mask:
_ZEND_TYPE_TYPE_PARAMETER_BIT (1u << 25) and
_ZEND_TYPE_NAMED_WITH_ARGS_BIT (1u << 31), with payload structs
zend_type_parameter_ref { zend_string *name; uint32_t index;
uint8_t origin; } and zend_type_named_with_args { zend_string
*name; uint32_t name_attr; uint32_t count; zend_type args[]; }.
These bits only ever appear in pre-erasure forms held by the
side table; runtime arg_info / property / class-constant types
never carry them. Helpers: ZEND_TYPE_HAS_TYPE_PARAMETER(),
ZEND_TYPE_TYPE_PARAMETER(), ZEND_TYPE_HAS_NAMED_WITH_ARGS(),
ZEND_TYPE_NAMED_WITH_ARGS().
. New compiler-globals fields: CG(type_arg_depth) (right-angle
split state used by the zendlex wrapper), CG(token_residual)
(single-token pushback slot), and CG(generic_scope) (linked
stack of in-scope type parameters).
. New T_TURBOFISH lexer token (literal `::<`). The zendlex wrapper
splits T_SR (`>>`), T_IS_GREATER_OR_EQUAL (`>=`), and T_SR_EQUAL
(`>>=`) into separate `>` tokens whenever CG(type_arg_depth) is
non-zero, with a single-token pushback slot.
. Module API bumped to 20260506; extension API bumped to
420260506. All extensions must be recompiled.

========================
2. Build system changes
Expand Down
45 changes: 45 additions & 0 deletions Zend/Optimizer/optimize_func_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,46 @@ static void zend_delete_call_instructions(const zend_op_array *op_array, zend_op
}
}

/* Returns true if a VERIFY_GENERIC_ARGUMENTS sits between this call's INIT and
* DO opcodes; such a call cannot be inlined because the verify opcode reads
* EX(call), which goes away once the frame is dropped. */
static bool zend_call_has_generic_arguments_check(zend_op *opline)
{
int call = 0;
while (1) {
switch (opline->opcode) {
case ZEND_INIT_FCALL_BY_NAME:
case ZEND_INIT_NS_FCALL_BY_NAME:
case ZEND_INIT_STATIC_METHOD_CALL:
case ZEND_INIT_METHOD_CALL:
case ZEND_INIT_FCALL:
case ZEND_INIT_PARENT_PROPERTY_HOOK_CALL:
if (call == 0) {
return false;
}
ZEND_FALLTHROUGH;
case ZEND_NEW:
case ZEND_INIT_DYNAMIC_CALL:
case ZEND_INIT_USER_CALL:
call--;
break;
case ZEND_DO_FCALL:
case ZEND_DO_ICALL:
case ZEND_DO_UCALL:
case ZEND_DO_FCALL_BY_NAME:
call++;
break;
case ZEND_VERIFY_GENERIC_ARGUMENTS:
if (call == 0) {
return true;
}
break;
}

opline--;
}
}

static void zend_try_inline_call(zend_op_array *op_array, const zend_op *fcall, zend_op *opline, const zend_function *func)
{
const uint32_t no_discard = RETURN_VALUE_USED(opline) ? 0 : ZEND_ACC_NODISCARD;
Expand All @@ -97,6 +137,11 @@ static void zend_try_inline_call(zend_op_array *op_array, const zend_op *fcall,
return;
}

if (zend_call_has_generic_arguments_check(opline - 1)) {
/* The verify opcode must run; inlining would orphan it. */
return;
}

for (i = 0; i < num_args; i++) {
/* Don't inline functions with by-reference arguments. This would require
* correct handling of INDIRECT arguments. */
Expand Down
18 changes: 18 additions & 0 deletions Zend/tests/generics/declaration/default_satisfies_bound.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
--TEST--
Declaration: type-parameter default that satisfies its bound is accepted
--FILE--
<?php
class A {}
class B extends A {}

class Box<T : A = B> {}
function f<T : A = B>(): void {}
trait Tr<T : A = B> {}
interface I<T : A = B> {}

new Box;
f();
echo "OK\n";
?>
--EXPECT--
OK
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
--TEST--
Declaration: class type-parameter default that does not satisfy its bound is rejected
--FILE--
<?php
class Animal {}
class Box<T : Animal = int> {}
?>
--EXPECTF--
Fatal error: Default int for type parameter T does not satisfy its bound Animal in %s on line %d
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
--TEST--
Declaration: function type-parameter default that does not satisfy its bound is rejected
--FILE--
<?php
class Animal {}
function id<T : Animal = int>(): void {}
?>
--EXPECTF--
Fatal error: Default int for type parameter T does not satisfy its bound Animal in %s on line %d
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
--TEST--
Declaration: interface type-parameter default that does not satisfy its bound is rejected
--FILE--
<?php
class Animal {}
interface I<T : Animal = int> {}
?>
--EXPECTF--
Fatal error: Default int for type parameter T does not satisfy its bound Animal in %s on line %d
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
--TEST--
Declaration: trait type-parameter default that does not satisfy its bound is rejected
--FILE--
<?php
class Animal {}
trait Holder<T : Animal = int> {}
?>
--EXPECTF--
Fatal error: Default int for type parameter T does not satisfy its bound Animal in %s on line %d
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
--TEST--
Declaration: default checked against intersection bound when types are concrete
--FILE--
<?php
class Box<T : Traversable & Countable = int> {}
?>
--EXPECTF--
Fatal error: Default int for type parameter T does not satisfy its bound Traversable&Countable in %s on line %d
8 changes: 8 additions & 0 deletions Zend/tests/generics/declaration/default_with_union_bound.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
--TEST--
Declaration: default checked against union bound when types are concrete
--FILE--
<?php
class Box<T : int | string = float> {}
?>
--EXPECTF--
Fatal error: Default float for type parameter T does not satisfy its bound string|int in %s on line %d
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
--TEST--
Bound error message: declaration-time default-vs-bound error renders NAMED_WITH_ARGS in the bound
--FILE--
<?php
interface Comparable<T> {}
class Box<T : Comparable<T> = int> {}
?>
--EXPECTF--
Fatal error: Default int for type parameter T does not satisfy its bound Comparable<T> in %s on line %d
10 changes: 10 additions & 0 deletions Zend/tests/generics/erasure/arrow_fn_erasure.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
--TEST--
Erasure: arrow function with type parameters erases to bound
--FILE--
<?php
$f = fn<T : int>(T $x): T => $x;
$r = new ReflectionFunction($f);
echo $r->getParameters()[0]->getType()->__toString(), "\n";
?>
--EXPECT--
int
12 changes: 12 additions & 0 deletions Zend/tests/generics/erasure/bound_object.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
--TEST--
Erasure: T : object erases to object
--FILE--
<?php
function id<T : object>(T $x): T { return $x; }
$r = new ReflectionFunction('id');
echo $r->getParameters()[0]->getType()->__toString(), "\n";
echo $r->getReturnType()->__toString(), "\n";
?>
--EXPECT--
object
object
13 changes: 13 additions & 0 deletions Zend/tests/generics/erasure/bound_to_class.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
--TEST--
Erasure: T : Foo erases to Foo
--FILE--
<?php
class Foo {}
function id<T : Foo>(T $x): T { return $x; }
$r = new ReflectionFunction('id');
echo $r->getParameters()[0]->getType()->getName(), "\n";
echo $r->getReturnType()->getName(), "\n";
?>
--EXPECT--
Foo
Foo
18 changes: 18 additions & 0 deletions Zend/tests/generics/erasure/builtin_array_erasure.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
--TEST--
Erasure: array<...> erases to plain array at runtime
--FILE--
<?php
function f(array<int> $x): array<string, int> { return $x; }
$r = new ReflectionFunction('f');
$pt = $r->getParameters()[0]->getType();
echo $pt->getName(), "\n";
$rt = $r->getReturnType();
echo $rt->getName(), "\n";

f([1, 2, 3]);
echo "ok\n";
?>
--EXPECT--
array
array
ok
10 changes: 10 additions & 0 deletions Zend/tests/generics/erasure/closure_erasure.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
--TEST--
Erasure: closure with type parameters erases to bound
--FILE--
<?php
$f = function <T : object>(T $x): T { return $x; };
$r = new ReflectionFunction($f);
echo $r->getParameters()[0]->getType()->__toString(), "\n";
?>
--EXPECT--
object
11 changes: 11 additions & 0 deletions Zend/tests/generics/erasure/composite_bound.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
--TEST--
Erasure: composite bound erased
--FILE--
<?php
class A {}
function f<T : A|int>(T $x): T { return $x; }
$rt = (new ReflectionFunction('f'))->getReturnType();
echo get_class($rt), "\n";
?>
--EXPECT--
ReflectionUnionType
20 changes: 20 additions & 0 deletions Zend/tests/generics/erasure/erased_signature_match.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
--TEST--
Erasure: generic signature matches hand-erased equivalent
--FILE--
<?php
class Foo {}

class Generic<T : Foo> {
public function get(): T { return new Foo; }
}

class Erased {
public function get(): Foo { return new Foo; }
}

$rg = (new ReflectionClass('Generic'))->getMethod('get')->getReturnType()->__toString();
$re = (new ReflectionClass('Erased'))->getMethod('get')->getReturnType()->__toString();
var_dump($rg === $re);
?>
--EXPECT--
bool(true)
Loading
Loading