Limitations
August 10, 2026 · View on GitHub
Every entry here is a thing this package cannot do, paired with what to do instead. That pairing is the point: a limitation stated on its own reads as an apology, and a limitation with its mitigation next to it is a design constraint you can work with.
They fall into three groups, and the difference between them matters more than any individual entry:
- Loud — rejected at specialization time with a named exception. You find out immediately.
- Structural — the specialization works exactly as documented; it simply is not the thing people assume it is. You find out by reading, which is why these are first.
- Quiet — the code runs, nothing throws, and less is checked than it looks like.
array<T>element types is the only one, and it is the most important paragraph in this file.
Contents
- Structural: what a specialization is
- Quiet: where less is checked than it looks
- Loud: rejected at specialization time
- Not built, and not planned for 1.0
- Cost, not correctness
- Environment
Structural: what a specialization is
A specialization is a sibling, not a subclass
$box instanceof Box is false for Box<int>, and no amount of work makes it true.
Why. The specialization is a copy of the template's zend_class_entry. A copy inherits
what the original inherited — the same parent, the same interface list — but the copy is not
placed underneath the original in the hierarchy, because nothing in the template's class entry
says it should be.
What to do instead. Type-hint an interface or an abstract base; both are preserved onto every
specialization. GenericObject is required precisely so that at least one relation always
survives. Where you would reach for instanceof, ask the question a different way:
Generic::isSpecialization($box); // true
Generic::isSpecialization($box, Box::class); // true - "made from this template"
Generic::templateOf($box); // 'App\Box'
Generic::bindingOf($box); // ['int']
The shipped PHPStan rule reports instanceof against a template, so this is caught in analysis
rather than in production. catch (Box $e) has the same problem for the same reason, and has its
own rule.
self::class and __CLASS__ name the template
Inside a method body, both still say App\Box even on App\Box<int>.
Why. The compiler folded them into the opcodes at compile time, and the specialization shares those opcodes — that sharing is the memory result this package exists to demonstrate. Un-sharing every method to rewrite two constants would cost exactly what the approach is meant to save.
What to do instead. static::class is resolved at run time and correctly names the
specialization. A PHPStan rule reports self::class inside a template.
Private property names keep the template's mangled prefix
Box<int>'s private $value is still stored under the name mangled for Box.
Why. The mangled name string is shared with the template rather than rebuilt.
What to do instead. Nothing. Property access is offset-based and entirely correct; this is visible only if you read the raw property table, and it is cosmetic there too.
Specialized names cannot be written in PHP source
There is no way to type Box<int> as a class name, and no PSR-4 autoloader can resolve it.
Why. Deliberate. A name containing < is one no PHP source can declare, which is what makes a
collision between a specialization and a real class impossible rather than merely unlikely.
What to do instead. Box::of('int') returns the name; the shipped PHPStan extension narrows
it to class-string<Box<int>> so static analysis follows along. The name still reads correctly in
get_class(), var_dump() and stack traces. If some tool in your chain genuinely cannot cope,
IdentifierSafeNameMangler produces App\Generic\Box_int instead — see the README for what that
costs.
A specialization with the default name cannot be autoloaded
class_exists('App\Box<int>') is false and no autoloader is consulted — not this package's,
not Composer's, not yours.
Why. PHP consults the autoload stack only for names that are valid class names: a label,
optionally with namespace separators. A name containing < fails that test before any autoloader
runs, so nothing in the process ever sees it. This follows directly from the entry above and is
the same property, viewed from the other side.
What to do instead. Mint what you need with Generic::warmUp() at boot, which is the right
answer anyway. If you genuinely need name-driven materialization — unserialize(), a
string-keyed DI container — combine GenericAutoloader with IdentifierSafeNameMangler, whose
names are valid identifiers. Note the second-order cost: that mangler's parse() is best-effort,
so a specialization on a class-typed argument cannot be recovered from its name and the autoloader
will answer false for it. Builtin type arguments round-trip exactly.
Quiet: where less is checked than it looks
array<T> and iterable<T> element types are not enforced
This is the one to read twice. A slot declared array and documented array<T> gets its
top-level type checked and nothing else. Collection<int> will accept
['not', 'ints', 'at', 'all'] at run time without a murmur, because what the engine checks is
"is this an array", and it is.
Why. zend_type has no parametric array type. There is nothing to write into the slot that
would express "array of int", so there is nothing for the engine to check.
What to do instead. Understand exactly which of your two type checkers is doing the work.
The @param array<T> doc tag is real and PHPStan enforces it statically — that part is not
weakened. What is missing is the run-time half, which everywhere else in this package is the part
you are relying on. If a boundary needs run-time element checking (decoded JSON, a queue payload,
anything crossing a process edge), check the elements yourself; a specialization will not do it
for you and will not tell you it did not.
This is the most likely source of false confidence in the package, which is why it is stated this bluntly.
For scalars, there is now a way out. A
native data vector puts the elements in a block of memory instead of an
array, which moves the element type from a slot the engine cannot check (array) to method slots
it can: NativeVector<int> really does reject 1.5, with the engine's own TypeError. It
covers int and float today. It is not a general answer — an array<User> is still an
array — and it is a different data structure rather than a fix to this entry.
Loud: rejected at specialization time
Everything in this group throws a named exception before the engine is touched, so a rejected call never leaves a half-registered class behind.
A return type the compiler emitted no check for cannot be re-typed
A mixed return, or one the compiler already proved satisfies the declared type.
Why. A return value is checked by a ZEND_VERIFY_RETURN_TYPE opline that reads arg_info.
Where the compiler emitted no such opline, rewriting arg_info changes what reflection reports
and nothing else — the class would silently stop checking.
What to do instead. Give the method a non-mixed return type and a return the compiler cannot
fold. SpecializationException names the method; it is never accepted quietly.
A builtin parameter cannot be re-typed when the body is opcache-shared
Why. A plain parameter is checked against a type mask the compiler cached into the
ZEND_RECV opline, so re-typing one means patching that cache and therefore un-sharing the
method's opcode array. Copying an opcode array keeps its literals where they are, and an
IS_CONST operand addresses them with a 32-bit offset — so the copy is rejected when the literal
table ends up more than 2 GB away, which is what happens when the body lives in opcache shared
memory and the copy does not.
What to do instead. Declare that parameter with a placeholder type (T) rather than a builtin
plus #[Of]. A placeholder parameter reads arg_info directly and needs no un-sharing, so it is
unaffected. Tracked upstream as
z-engine#131.
Union and intersection type arguments
Box<int|string> and Box<Countable&Traversable> are refused.
Why. No single zend_type can hold them without building a type list, which is a heap
structure the substitution path does not construct.
What to do instead. Specialize for one of them, or declare the union in the template itself where the compiler builds the list.
Nullable type arguments
Box<?int> is refused.
Why. Substitution preserves whatever nullability the template declared and cannot introduce
it, so accepting ?int would quietly produce a non-nullable slot. Leniency here would move
this entry into the quiet group, which is the worse outcome.
What to do instead. Declare the slot as ?T in the template.
iterable, callable, void, never, resource, static, self, parent
Refused as type arguments.
Why. iterable is array|Traversable and has no single type mask; the rest are not types a
declaration slot can hold at all. Accepting iterable would be the worst case — it would be
treated as the name of a class called "iterable".
What to do instead. Use array or Traversable explicitly.
Templates must be plain userland classes
The specializer rejects interfaces, traits, enums, internal classes, classes with an internal ancestor, unlinked classes, and classes with property hooks.
Why. Each of these has state the copy cannot own safely — an internal ancestor's handlers, a trait's flattening, an enum's case table, a hook's closures.
What to do instead. Keep the template a plain class and put the shared identity on an interface. Generic interfaces stay non-generic at run time, which is the recommended identity pattern anyway.
A type parameter declared by an ancestor cannot be substituted
Why. Inherited property_info and arg_info are shared with the declaring class. Rewriting
them would change the ancestor for every other subclass in the process.
What to do instead. Declare generic slots on the template itself.
Not built, and not planned for 1.0
Generic methods and generic functions
function first<T>(array $items): T {} // no
public function map<U>(callable $fn): U {} // no
Why. The engine primitive this package is built on specializes a class entry. There is no equivalent for a single method or a free function: nothing to copy, nothing to key a specialization on, and no name to register it under. This is not a gap in the implementation, it is the absence of the thing the implementation stands on.
What to do instead. Put the type parameter on a class. Out of scope for 1.0, and honestly so.
Specialization during opcache.preload
Why. The preload request's allocations are released at its end, so a class entry created there does not survive into the requests that follow.
What to do instead. Preload the templates — that is fine and useful — and specialize at
worker boot. See long-running.md and the repository's preload.php.
Anything surviving the request
Class entries are request memory. Nothing registered by specialize() outlives the request or the
worker.
What to do instead. Warm up at boot; budget the warm-up in FPM. Fully covered in
long-running.md.
Cost, not correctness
A class-typed property write costs about 2.5x a compiled one
Measured, not assumed: benchmarks.md has the numbers and the probe that found
the cause.
Why. The cost tracks the length of the type argument's class name, which is what cost spent resolving a name looks like. A compiled class resolves its property type once through a class-entry cache attached to interned strings; the name z-engine writes into a substituted type is created at run time and is not interned, so the fast path is missed. Suspected mechanism, not proven.
What to do instead. Prefer a builtin type argument where the choice exists — a builtin-typed
property write is at parity, because there is no name to resolve. Nested generics are the worst
case, since their type-argument names are long by construction. Tracked upstream as
z-engine#130; if it is fixed there, re-run
composer bench and this entry goes away.
Everything else — method dispatch, class-typed parameters, builtin-typed properties — measured at parity with a hand-written class.
A stub-described template does not narrow of()
Affects static analysis only; nothing about the run time changes. A placeholder-form template
declares its type parameter as a native type, which an analyser resolves to a class that does not
exist, so the package ships a generated stub describing the class the way it behaves. Once
that stub is in your PHPStan configuration, NativeVector::of('int') stays
class-string<NativeVector<T>> instead of narrowing to class-string<NativeVector<int>>.
Why. A stub cannot name this library's own interfaces — stub files are reflected before the
analysed paths are indexed — so the stubbed class is not a GenericObject, and
TemplateOfReturnTypeExtension is registered against exactly that marker (it has to be: of()
is a trait method, and a trait is not in any class's ancestry). The two mechanisms are
therefore exclusive: the stub gives you element types, the marker gives you of() narrowing.
What to do instead. Spell the specialization where it enters your code and let inference do
the rest — /** @var NativeVector<int> $vector */ once, and $vector->get(0) is int,
iterator_to_array($vector) is array<int, int> and $vector[0] is int|null from there on.
The setup and the exact neon snippet are in
native-vectors.md. Attribute-form templates are unaffected:
they need no stub, so of() narrows for them as it always did.
Environment
PHP 8.4, NTS, x86-64, ffi.enable=1, opcache.jit=off
Why. Engine struct layouts are version- and build-specific, and the JIT rewrites the executor internals z-engine hooks into.
What to do instead. Mirror z-engine's branch-per-minor model: this package tracks one PHP
minor at a time, the same one the z-engine branch it depends on tracks. Core::init() refuses to
boot against a mismatch rather than corrupting memory, and that guard is not something to defeat.