Download the PHP package lisachenko/userland-php-generics without Composer

On this page you can find all versions of the php package lisachenko/userland-php-generics. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package userland-php-generics

# 🧬 Userland PHP Generics ### Reified generics for PHP. In userland. Today. **`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.** [![PHP Version](https://img.shields.io/badge/php-8.4%20%7C%208.5-8892BF.svg)](https://www.php.net/) [![License](https://img.shields.io/packagist/l/lisachenko/userland-php-generics.svg)](LICENSE) [![PHPStan](https://img.shields.io/badge/PHPStan-level%20max-brightgreen.svg)](https://phpstan.org/)

⚠️ Experimental — not for production. A proof of concept built on lisachenko/z-engine, which manipulates Zend Engine internals through FFI.

Contents

Why this exists

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.

How it looks

The template itself is left exactly as it was: Box::$value still has the placeholder type, so an unspecialized Box accepts nothing at all.

How it works

  1. TemplateParser reads the #[TemplateParameter] attributes and walks the class's own properties, parameters and return types looking for slots that carry a type parameter.
  2. TypeArgumentResolver parses and validates the arguments — including nested generics, which resolve eagerly and depth-first because the outer slot stores nothing but the inner class name.
  3. AngleBracketNameMangler derives the runtime name, Box<int>.
  4. The substitution strategies contribute to one SubstitutionPlan, expressed entirely in this package's own types.
  5. Monomorphizer — the single class in the package that talks to z-engine — translates the plan into the engine's vocabulary and asks ClassSpecializer to deep-clone the class entry, rewrite the types and register the result in EG(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.

Naming

A specialization is called App\Box<int>, and the angle brackets are load-bearing rather than decorative:

Caching

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::reset() clears both without touching the class table — the classes stay registered because they are engine state, so a later lookup adopts them again.

Warm-up

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.

See Long-running processes for the budget and docs/long-running.md for the full account.

An identifier-safe name, at a price

Some tooling insists on class names that are legal PHP identifiers. IdentifierSafeNameMangler produces App\Generic\Box_int for those cases:

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.

Autoloading, if you want it — and only with that mangler

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:

It 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:

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

Requirements and installation

Z-Engine owns the environment checks and explains anything it cannot support, so there is nothing to probe here.

Writing templates

Every template must:

#[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.

The two forms

Placeholder form — the slot declares the type parameter as its native type:

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:

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.

Bounds

mirrors @template T of Countable, and is checked at specialization time.

Type arguments

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.

What the engine actually enforces

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.

Identity: sibling, not subclass

A specialization is a sibling of its template, not a subclass. $intBox instanceof Box is false.

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:

Static analysis

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:

It 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:

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

What it costs

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.

Long-running processes

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.

docs/long-running.md has the per-request budget, the worker and FPM recipes and a deployment checklist.

Native data vectors

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:

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

A runnable example

examples/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.

Known limitations

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:

Design notes

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.

Contributing

See AGENTS.md.

License

Released under the MIT License.


All versions of userland-php-generics with dependencies

PHP Build Version
Package Version
Requires php Version ^8.4
ext-ffi Version *
lisachenko/z-engine Version ~8.4.2 || ~8.5.0
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package lisachenko/userland-php-generics contains the following files

Loading the files please wait ...