Box::of('int') does not return a wrapper, a validator or a docblock promise. It returns a
real class whose int is enforced by the Zend Engine itself — the same TypeError you get
from a hand-written class, on a class that did not exist a microsecond ago.
⚠️ Experimental — not for production. A proof of concept built onlisachenko/z-engine, which manipulates Zend Engine internals through FFI.
- Why this exists
- How it looks
- How it works
- Requirements and installation
- Writing templates
- Type arguments
- What the engine actually enforces
- Identity: sibling, not subclass
- Static analysis and the extension guide
- Long-running processes and the deployment guide
- Native data vectors and the memory model
- A runnable example
- Known limitations, in full in docs/limitations.md
- What it costs and the full benchmark report
- Design notes and the full design document
- Contributing
The PHPGenerics RFC and the PHP Foundation's work on compile-time generics keep running into the same objection: monomorphization costs too much memory to put inside php-src. Nobody has published real numbers, because until recently nobody could monomorphize a PHP class at all.
This package can. It uses z-engine's ClassSpecializer to deep-clone a template's
zend_class_entry under a new name and rewrite the zend_type of its properties, parameters
and return types — while sharing the compiled method bodies through the engine's own
op_array refcount. The marginal cost of a specialization is therefore roughly
sizeof(zend_op_array) per method, independent of how large those methods are, rather than a
full copy of the opcodes.
Measuring that was half the point of this repository, and it is now done. Specializing a
32-method template costs 34,448 bytes whether each method holds 4 statements or 200 — the
same number to the byte — while generating and compiling the equivalent concrete class costs
22,480 and 399,312 bytes respectively. Full method, environment and caveats in
docs/benchmarks.md, which is generated by composer bench.
use Lisachenko\Generics\Attribute\TemplateParameter;
use Lisachenko\Generics\{GenericObject, GenericTemplate};
/**
* @template T
*/
#[TemplateParameter('T')]
final class Box implements GenericObject
{
use GenericTemplate;
private ?T $value = null;
public function set(T $value): void
{
$this->value = $value;
}
public function get(): ?T
{
return $this->value;
}
}$intBox = new (Box::of('int'))();
$intBox->set(42); // fine
$intBox->set('nope'); // TypeError: Box<int>::set(): Argument #1 ($value) must be of type int
get_class($intBox); // "Box<int>" — a real, registered, instantiable classThe template itself is left exactly as it was: Box::$value still has the placeholder type, so
an unspecialized Box accepts nothing at all.
TemplateParserreads the#[TemplateParameter]attributes and walks the class's own properties, parameters and return types looking for slots that carry a type parameter.TypeArgumentResolverparses and validates the arguments — including nested generics, which resolve eagerly and depth-first because the outer slot stores nothing but the inner class name.AngleBracketNameManglerderives the runtime name,Box<int>.- The substitution strategies contribute to one
SubstitutionPlan, expressed entirely in this package's own types. Monomorphizer— the single class in the package that talks to z-engine — translates the plan into the engine's vocabulary and asksClassSpecializerto deep-clone the class entry, rewrite the types and register the result inEG(class_table).
Every validation happens before the engine is asked for anything, so a rejected call can never leave a half-registered class behind.
Step 1 has a second consumer: bin/generics-stubs renders the same slots as mixed plus doc
tags for PHPStan. Sharing the parser is what makes the generator accept exactly what the runtime
accepts — see Static analysis.
A specialization is called App\Box<int>, and the angle brackets are load-bearing rather than
decorative:
- no PHP source can declare a class whose name contains
<, and no PSR-4 autoloader can resolve one, so a specialization can never collide with a real class — the one naming hazard z-engine's own docs call out; - the name still reads correctly in
get_class(),var_dump()and stack traces, which is worth a great deal when debugging a class that exists only at runtime; - nested arguments nest naturally:
App\Box<App\Box<int>>.
Specializations live in the engine's class table for the rest of the request, and
specialize() refuses a duplicate name. The cache therefore adopts rather than fails: a
name that is already registered — by another factory instance, by a warm-up at worker boot, or
after the cache was cleared with forget() — is recorded and returned instead of being built a
second time.
Beside the cache sits a registry of what each specialization was made from, written when the class is minted rather than recovered from the name afterwards:
Generic::registry()->bindingFor($intBox::class)?->typeArguments; // ['int']Generic::reset() clears both without touching the class table — the classes stay registered
because they are engine state, so a later lookup adopts them again.
Minting is a start-up cost, not a request cost: it runs to roughly a hundred microseconds plus
another hundred per own method, while asking for one that already exists is about two.
warmUp() is the shape that difference implies.
Generic::warmUp([
[Box::class, ['int']],
[Box::class, [User::class]],
]);See Long-running processes for the budget and
docs/long-running.md for the full account.
Some tooling insists on class names that are legal PHP identifiers.
IdentifierSafeNameMangler produces App\Generic\Box_int for those cases:
$factory = new GenericFactory(mangler: new IdentifierSafeNameMangler());It is opt-in because it gives up the property that makes the default name safe. App\Generic\Box_int
is a name somebody could declare by hand, and flattening \ into the same _ that separates
arguments means the name can no longer always be read backwards. The registry is what keeps
templateOf() and bindingOf() exact under it; anything this process did not mint gets the
mangler's best effort.
unserialize(), var_export() output and string-keyed DI containers all name a class and expect
it to exist. An opt-in autoloader builds it on demand:
use Lisachenko\Generics\Runtime\GenericAutoloader;
GenericAutoloader::register($factory); // never installed for you
class_exists('App\Generic\Box_int'); // true - minted on the spotIt cannot work with the default angle-bracket names, and that is PHP's rule rather than
ours. The engine consults the autoload stack only for names that are valid class names, so a
name containing < never reaches any autoloader at all:
spl_autoload_register(fn ($name) => print $name);
class_exists('App\Box<int>'); // prints nothing whatsoeverWhich is a useful fact in its own right — an angle-bracket name can never be resolved behind your back — but it does mean the autoloader is inert unless you also opt into the identifier-safe mangler above. It is never registered automatically either way, because installing a global autoloader is a side effect a library should not impose.
- PHP 8.4 or 8.5 (supported in parallel), NTS, x86-64
ext-ffiwithffi.enable=1opcache.jit=off(the JIT rewrites the executor internals z-engine hooks into)lisachenko/z-engine— the8.4.x-dev || 8.5.x-devconstraint lets composer resolve the z-engine line matching your PHP minor (the8.4branch on PHP 8.4,masteron PHP 8.5); z-engine owns all version-dependent header complexity, so this package never deals with it
composer require --dev lisachenko/userland-php-genericsuse Lisachenko\Generics\Generic;
Generic::bootstrap(); // optional: boots Z-Engine now instead of on first specializationZ-Engine owns the environment checks and explains anything it cannot support, so there is nothing to probe here.
Every template must:
- declare its type parameters with
#[TemplateParameter], once per parameter, inof()order; - implement
GenericObject; use GenericTemplateif you wantof()on it (or callGeneric::specialize()instead, for classes you do not control).
#[TemplateParameter] is an attribute rather than the @template doc tag on purpose. With
opcache.save_comments=0 — a perfectly normal production setting — doc_comment is NULL, so
a doc-comment-driven runtime would break in exactly the environment people deploy to. The
@template tag stays the source of truth for static analysis; the runtime never reads one, and
a CI job runs the whole suite with save_comments=0 to keep it that way.
Placeholder form — the slot declares the type parameter as its native type:
private ?T $value = null;
public function set(T $value): void {}T is a class-like type name that is never defined as a real class, which is exactly what the
engine keys on. This form works for properties, parameters and return types.
Attribute form — the slot announces its type parameter instead:
#[Of('T')]
private mixed $value = null;This is the only way to re-type a slot declared mixed, because mixed has no type name to
match on. It works on properties, parameters and return types alike; on a slot that already
declares a placeholder it is still useful, mapping one placeholder class onto a
differently-named type parameter.
A promoted constructor property carrying #[Of] produces two slots, the parameter and the
property, because reflection reports the attribute on both.
Both forms may be mixed freely in one class.
#[TemplateParameter('T', of: Countable::class)]mirrors @template T of Countable, and is checked at specialization time.
The accepted grammar is deliberately a subset of PHPStan's type syntax, so the same strings mean the same thing to the runtime and to static analysis:
| Written | Meaning |
|---|---|
int, float, string, bool, true, false, null, array, object, mixed |
builtin types |
App\User |
any existing class, interface or enum |
App\Box<int> |
a nested generic, materialized innermost-first |
?int |
rejected — see below |
int|string, Countable&Traversable |
rejected, no zend_type can hold them |
iterable, callable, void, never, resource, static, self, parent |
rejected |
iterable deserves its own mention: it is array|Traversable, so it has no single engine type
mask and would otherwise be silently treated as the name of a class called "iterable".
Nullable arguments are refused rather than silently mishandled. Substitution preserves
whatever nullability the template declared and cannot introduce it, so Box<?int> would
quietly produce a non-nullable slot. Declare the slot as ?T in the template instead.
Nesting depth is capped (default 8). No finite argument can recurse forever — the brackets are written out — so the cap is a stack guard, not a cycle guard.
Everything a template declares can be re-typed — properties, parameters and return types, whether
they were declared as a placeholder class or as a builtin like mixed. Two return-type cases are
the exception, and they are rejected rather than silently unenforced:
| Slot | Re-typable |
|---|---|
| Property | always |
| Parameter | always |
| Return type | unless the compiler emitted no check for it — a mixed return, or a return it already proved satisfies the declared type |
The mechanics behind that are worth knowing if you are extending this, because rewriting a type
and having it enforced are not the same thing: a property write consults zend_property_info, a
return value is checked by an opline that reads arg_info, and a plain parameter is checked
against a mask the compiler cached into the ZEND_RECV opline — which is why a parameter also
needs that cached mask patched, and why doing so means un-sharing the method's opcode array.
docs/design.md covers all of it: how each check is reached, what the compiler
elides and why, and the relocation rules that make copying an opcode array safe.
A specialization is a sibling of its template, not a subclass.
$intBox instanceof Boxisfalse.
This is the copy model — the specialization shares the template's parent and interfaces rather than extending it — and it is not fixable. What follows from it:
- Type-hint an interface or an abstract base, never the template class. Both are preserved onto every specialization.
GenericObjectis required precisely so that at least one relation always survives.- Ask the question a different way:
Generic::isSpecialization($box),Generic::isSpecialization($box, Box::class),Generic::templateOf($box)andGeneric::bindingOf($box)all read the runtime name, so they also work for instances minted by another factory. The shipped PHPStan rule points at them. catch (Box $e)has the same problem, for the same reason.self::classand__CLASS__inside a method body still name the template, because the compiler folded them into the shared opcodes.static::classis correct and resolves to the specialization.
interface BoxInterface extends GenericObject {}
/** @template T @implements BoxInterface<T> */
#[TemplateParameter('T')]
final class Box implements BoxInterface { use GenericTemplate; }
function consume(BoxInterface $box): void {} // works for every Box<X>The @template doc tags are what PHPStan and your IDE read, and they keep working normally.
The shipped extension adds the two things they cannot do on their own — see
docs/static-analysis.md for the full account.
It infers the specialization. With phpstan/extension-installer there is nothing to
configure:
$box = new (Box::of('int'))(); // inferred as Box<int>
Box::of('int'); // class-string<Box<int>>
Box::of($runtime); // class-string<Box> - not narrowed, and not wrongIt reports what instanceof cannot. $box instanceof Box is always false, nothing about
the call site looks wrong, and without a rule the only way to find out is to ship it. Six more
rules cover catch, drift between #[TemplateParameter] and @template, unsupported type
arguments, misapplied #[Of], self::class, and property hooks.
It generates the stubs the placeholder form needs. A native T beats any @param T, so
PHPStan needs a stub that describes the class as it behaves. You do not write those:
vendor/bin/generics-stubs --out=var/generics-stubs 'App\Box'parameters:
stubFiles:
- var/generics-stubs/box-stub.php
scanFiles:
- var/generics-stubs/placeholders.php--check exits non-zero when a stub is out of date, for CI. The attribute form needs no stubs
for its properties, which is the concrete measure of its advantage.
Generated by composer bench; the full tables, the environment they were taken in and the
statistics used are in docs/benchmarks.md.
Memory. One specialization costs a class entry plus one zend_op_array struct per own
method — and nothing per statement in those methods, which is the claim this project exists to
check. Measured at 4 and at 200 statements per method, the cost is identical to the byte. The
exception is a mixed parameter: it is the only slot that has to un-share the method's
opcode array, so it alone scales with body size (~7x across that range).
Minting. About 220 us fixed plus 150 us per own method — not a hot-path operation, which
is the whole reason for the warm-up advice below. Asking for an already-minted specialization is
about 2 us, so of() is safe to write wherever a generic type appears.
Using. A specialized method dispatches at parity with a hand-written class, and so does a
builtin-typed property write. A class-typed property write costs more than 2x, and that cost
scales with the length of the type argument's class name — a compiled class resolves that name once, a
specialization appears to resolve it on every write. It is the one measured non-parity in the
package, it is worst for nested generics (whose type argument names are long by construction),
and it is written up in docs/design.md.
Specialization is request-scoped: the class entry and its tables are request memory, and the registration lives until the request (or worker) ends. Nothing survives shutdown.
- In a worker runtime (RoadRunner, Swoole, FrankenPHP)
Generic::warmUp()a written-down list at boot; every request after that gets a ~2 us cache hit. - In FPM you pay the warm-up per request; budget it against the measured cost.
- Specializing during
opcache.preloadis not supported — the preload request's allocations are released at its end. Preloading the templates is fine and useful, which is what the shippedpreload.phpis for. - Never specialize on a type argument that came from request input: each distinct argument mints a class that lives as long as the worker does.
docs/long-running.md has the per-request budget, the worker and FPM
recipes and a deployment checklist.
array<T> element types are the one thing this package cannot enforce at run time (see
Known limitations). For scalars there is now a data structure that can:
use Lisachenko\Generics\Native\NativeVector;
$samples = new (NativeVector::of('int'))($blobFromTheWire);
echo $samples[0]; // a zend_long read straight out of the block
$samples[1] = -20; // written straight back into it
$samples->append(50); // the block grows by eight bytes
$samples->append(1.5); // TypeError, from the engine
$bytes = $samples->toBinary(); // back to a PHP string, byte for byteThe block of memory is a PHP string. Element i lives at byte i * 8 of that string's
zend_string.val, and every accessor reaches it as a zend_long * or a double * — there is
no encoding step, so pack()/unpack() appear nowhere on the path. The element type is
enforced because get(), set() and append() are declared with the type parameter, which is
a slot the engine really does check; the array syntax delegates to them rather than replacing
them.
docs/native-vectors.md has the memory model, the copy-on-write
discipline that makes toBinary() safe to hand out, the static-analysis setup, and the roadmap
towards sized scalar kinds and C structures.
php -d ffi.enable=1 -d opcache.jit=off examples/collection.php
php -d ffi.enable=1 -d opcache.jit=off examples/native-vector.phpexamples/collection.php specializes a collection template, prints
get_class(), shows the engine's own TypeError rejecting the wrong element type, and shows the
template left exactly as it was. examples/native-vector.php casts
a binary blob to a NativeVector<int>, indexes it, grows it and hands it back as a string. Both
are covered by tests that run them, so they cannot quietly stop working.
Every one of them, with its cause and its mitigation, is in
docs/limitations.md. Most are rejected loudly at specialization
time, which makes them easy to live with. Three are not, and are worth knowing before you read
anything else:
array<T>anditerable<T>element types are not enforced.zend_typehas no parametric array type, so a slot declaredarrayis checked for being an array and nothing more. The doc tag still carries the element type and PHPStan still enforces it — but statically only. This is the one place where less is checked at run time than it looks, and therefore the most likely source of false confidence in the package. For scalar elements, native data vectors are the way out.- A specialization is a sibling, not a subclass.
$box instanceof Boxisfalseand cannot be made true. Type-hint an interface or an abstract base; both are preserved. - Everything is request-scoped. Class entries are request memory. Specialize at worker boot —
and never during
opcache.preload, whose allocations are released at its end. Seedocs/long-running.md.
Four alternatives were considered and rejected; they are recorded here because the reasons are
the interesting part. docs/design.md has them in full, alongside the
measurements that settled them.
Compile-time AST rewriting. Rewriting mixed into a placeholder through
Core::setASTProcessHandler() would have given the nicest source syntax. But
zend_ast_process does not fire on an opcache cache hit — i.e. on every production deploy
and every second CLI run with a file cache. The template would compile from cache with mixed
intact, substitution would find nothing, and specialize() would report success while
producing a class that enforces nothing. A generics library whose type checking evaporates
under opcache is worse than none.
Doc-comment-driven templates. Reading @template at runtime breaks under
opcache.save_comments=0. Attributes are the runtime source of truth; a CI job enforces it.
Recompiling method bodies. It would handle even the elided-check cases, correctly and by construction — but at the cost of a real compile per specialization and the loss of body sharing, which works directly against the memory result this project exists to measure.
Inserting the missing VERIFY_RETURN_TYPE oplines. Growing an opcode array means renumbering
every jump, live_range and try_catch_array entry — high risk for the two cases the up-front
rejection already handles honestly.
See CONTRIBUTING.md and AGENTS.md.
composer test # test suite
composer phpstan # level max
composer cs:check # coding standardsReleased under the MIT License.