Every shipped feature in xphp has trade-offs. This page collects the ones you'll hit in real code, in the order they're likely to bite, each with the underlying reason and the workaround.
Pages in the syntax tour link back to specific sections here using anchor links — search this page for the same heading text.
class Holder {
public int $v = 5;
public function go(): int {
$f = fn<T>(T $x): int => $x + $this->v;
return $f::<int>(2);
}
}Generic arrow `$f::<...>(...)` captures `$this`, which is not yet
supported. Rewrite as a method on the enclosing class, or extract
the value of $this->property into a local variable before the arrow.
The same error fires for generic closures whose body references
$this.
The closure variable rewrite produces a dispatcher closure that
forwards to top-level specialized functions. Top-level functions
can't see the enclosing class's $this, and PHP doesn't allow
use ($this) on a closure either. Carrying $this would need
either a method-rewriting pass (today's level) or rewriting $this->v
to a lifted mixed $__xphp_this param everywhere — a deeper
refactor that's queued up for later.
Either rewrite as a method on the class:
class Holder {
public int $v = 5;
public function go(): int {
return $this->add::<int>(2);
}
public function add<T>(T $x): int {
return $x + $this->v;
}
}Or extract the value to a local before the closure:
class Holder {
public int $v = 5;
public function go(): int {
$base = $this->v;
$f = fn<T>(T $x): int => $x + $base;
return $f::<int>(2);
}
}static arrows work: static fn<T>(T $x): T => $x specializes
exactly like a plain arrow (an arrow can never bind $this, so the
static is inert; note the rewritten dispatcher closure is technically
non-static — observable only through Closure::bind or reflection).
The gap below is specific to the static function (closure) syntax.
$f = static function<T>(T $x): T { return $x; };
$f::<int>(42);Generic static closures cannot yet be specialized at call sites.
Rewrite the call site for `$f::<...>(...)` to use a named generic
function at file scope.
The same form is rejected at parse time when combined with defaults:
$f = static function<T = int>(T $x): T { return $x; };Generic parameter `T` has a default value, which is not yet supported
on static closures. Drop the `static` modifier or assign the closure
to a named function.
Specializing an anonymous template at its call site landed in stages.
Plain generic closures and arrows are rewritten through the dispatcher
today; static closures (alongside explicit use (...) closures) are
a still-unimplemented branch of that rewrite. It's a capability gap,
not a binding one — a static closure has no $this to begin with, so
this is unrelated to the $this-capture
rejection above. The
named-function path is already complete, so lifting the body to a
file-scope generic function side-steps it.
Use an arrow, drop the static modifier, or lift the body to a named
function:
$f = static fn<T>(T $x): T => $x; // works
$f = function<T>(T $x): T { return $x; }; // works
// or
function id<T>(T $x): T { return $x; }
id::<int>(42); // worksA Closure(int $x): bool signature type is accepted anywhere a plain
type hint goes — a parameter, a return, a property, or nested inside
another signature. Two positions are not supported: a generic type
argument (Box<Closure(int): int>) and a generic bound
(class C<T : Closure(int): int>). Each is a clear compile error:
A Closure(...) signature type is not supported as a generic type argument
(closure signatures are allowed only in parameter, return, and property
types). Use a bare \Closure, or introduce a named type alias.
A signature parameter must also carry a type and cannot have a default value — a signature describes the callable's shape, not call-time values.
A signature erases to a bare \Closure before specialization, but a
generic argument or bound participates in specialization itself
(naming, hashing, subtype edges), where a structural type has no
identity to anchor to. Rejecting loudly keeps the cardinal rule: no
silent miscompile. Lifting these positions is on the
roadmap as a discovery item.
Use a bare \Closure in the generic position — you lose the
compile-time conformance check but keep a working type — or wrap the
callable in a named class:
class C<T : \Closure> {} // works: bare Closure bound
$b = new Box::<\Closure>(fn() => 1); // works: bare Closure argumentSee closure types → known limitations for the full list.
function process<out T>(T $x): T { /* ... */ } // free function
class Box<T> {
public function map<out U>(callable $f): Box<U> { /* ... */ } // method
}
$producer = function<out T>(): T { /* ... */ }; // closure
$arrow = fn<out T>(T $x): T => $x; // arrowVariance markers `out T` / `in T` are not supported on methods, functions,
closures, or arrow functions — variance is a class-level-only feature by
design: a function or closure specialization has no stable class identity
to anchor a subtype `extends` edge to. Move the generic to a class-level
type parameter.
This is a permanent design boundary, not a pending feature. Variance turns
into real extends chains between specialized classes (see
variance). Methods, functions, closures, and arrows
don't have a stable class identity to anchor an extends chain to — their
specializations are functions, not classes, so there's nothing for the subtype
edge to attach to. (This matches Kotlin, whose fun <R> map(...) is likewise
invariant.) Keep variance at the class level and let method-level type
parameters stay invariant.
Put the template on a named class and use its method:
class Producer<out T> {
public function __invoke(): T { /* ... */ }
}$triple = function<A, B, C>(A $a, B $b, C $c): array { return [$a, $b, $c]; };
$triple::<int, string, bool>(1, 'two', true);
// After compile:
$ref = new ReflectionFunction($triple);
$ref->getNumberOfParameters(); // returns 2 -- the dispatcher's
// (string $tag, mixed ...$args) shape,
// NOT the original three.
$ref->getParameters()[0]->getName(); // returns '__xphp_tag'Closure serializers (opis/closure,
laravel/serializable-closure) serialize the dispatcher closure
rather than the original body.
The variable holding the generic closure is rewritten to a
dispatcher closure with a fixed (string $__xphp_tag, mixed ...$__xphp_args)
signature. The real body lives in one or more top-level functions
keyed by the tag. Reflection only sees the dispatcher.
Pre-this-machinery, generic closures couldn't be specialized at all (they were rejected outright), so reflection-based serializers couldn't see anything. The 2-arg dispatcher shape is an improvement over a hard reject, just not a transparent one.
If your code path serializes closures via reflection, use a named generic function instead:
function pair<K, V>(K $k, V $v): array { return [$k, $v]; }
// Now reflection sees the real `pair_T_<hash>` specialization:
$ref = new ReflectionFunction('App\\pair_T_<hash-of-(string,int)>');
$ref->getNumberOfParameters(); // 2 (the real K, V)If you need the variable form for some other reason (currying, etc.), serialize the captured state separately and reconstruct the closure on the receiving side.
function decode<T>(string $json): T {
$data = json_decode($json, true);
return new T(...$data); // works in xphp
}
if ($x instanceof T) { /* ... */ } // works in xphp
$class = T::class; // works in xphpThe same code under a future RFC-aligned (erasure-based) PHP runtime
would NOT work — T would be erased at runtime, so new T(...),
instanceof T, and T::class all become meaningless.
xphp uses monomorphization: every Box<int> is a real class with
int baked into every signature. Inside the specialized body, T
literally becomes int, so reified-T operations Just Work.
The RFC's erasure model doesn't keep T at runtime, so any code relying on T being a runtime value would break on the future native PHP runtime.
If forward-portability to a future RFC-aligned runtime matters more
than reified-T ergonomics, avoid instanceof T, T::class, and
new T(...). Pass class names as explicit arguments instead:
function decode(string $json, string $class): mixed {
$data = json_decode($json, true);
return new $class(...$data);
}
$user = decode($payload, User::class);If forward-portability isn't a concern (you're not planning to target a future erased-T runtime), use reified-T freely — it's one of the things monomorphization makes naturally easy.
The README's "Heads up" banner mentions this divergence too.
$x = new Foo();
if ($cond) {
$x = new Bar();
}
$x->m::<int>($arg); // compile error: xphp.undetermined_receiverThe post-branch call fails to compile: the analysis can't prove a
single class for $x, and a turbofish call can only be specialized
against a known receiver type.
This is a conservatism issue, not a soundness one. xphp will NOT pick the wrong class, and it will NOT emit a runtime-broken call — it refuses at compile time and tells you to give
$xa known type.
Receiver-type analysis is conservative: when $x is reassigned inside a
branch and the arms don't agree on a class, the receiver's type is
undetermined. The generic method is stripped from its class at compile
time, so a non-specialized $x->m(...) would call a method that no
longer exists and fatal at runtime — so the compiler reports
xphp.undetermined_receiver instead of emitting it (ground or fail).
The same-arms-agree shape IS supported:
$x = new Foo();
if ($cond) {
$x = new Foo(); // same class as the other arm
}
$x->m::<int>($arg); // specializes against FooEither separate the call sites by branch:
if ($cond) {
$x = new Bar();
$x->m::<int>($arg); // specializes against Bar
} else {
$x = new Foo();
$x->m::<int>($arg); // specializes against Foo
}Or use a typed local before the call:
/** @var Foo|Bar $x */
$x = $cond ? new Bar() : new Foo();
// Manually call the right specialized name on each branch.A future version may add union-type tracking with runtime dispatch, but it's queued behind higher-priority work.
trait HasItem<T> {
public function set(T $item): void { /* ... */ }
}
class Container<out T> { // covariant
use HasItem<T>;
// The `set(T $item)` from the trait places T in a contravariant
// position. The validator should reject -- but it doesn't, because
// it doesn't walk into trait-imported methods.
}The variance validator emits no error today on trait-imported method signatures. If the trait's method places T in a forbidden position, the violation slips through compilation.
The validator walks ClassMethod nodes declared directly on the
ClassLike. Trait method bodies are stitched into the class by Zend
at compile time; the AST we walk pre-stitching doesn't see them.
Manually audit traits used by variant generic classes. If you control the trait, copy the body into the class directly so the validator can see it.
Scope: this affects only a multi-element covariant collection backed by an
arrayfield, at PHPStan level 6 and above. A covariant single-value container no longer hits this — store the element in aprivate Tproperty (PHP doesn't type-check private slots across theextendsedge), and the emittedget(): Tover a real-typedprivate Tfield is PHPStan-clean at every level. See theProducer<out T>example.
class ImmutableList<out T> {
private array $items; // many elements → `array` backing, not `T`
public function __construct(T ...$items) { $this->items = $items; }
public function get(int $i): T { return $this->items[$i]; }
}
$list = new ImmutableList::<Banana>(new Banana()); // compiles + runs finexphp compile is happy and the runtime is correct, but xphp check's
optional PHPStan-over-the-compiled-output pass,
at level 6 or higher, reports on the ImmutableList<Banana> specialization:
Property ...\ImmutableList\T_<hash>::$items type has no value type specified
in iterable type array.
[missingType.iterableValue]
(At level ≤5 it is clean — the missing-iterable-value-type rule only switches on at level 6.)
A collection holds many elements in one field, so the backing must be an array
(you can't fit them in a single private T slot). xphp substitutes type parameters
in signatures (the emitted get(): Banana is correct) but emits the backing as
a plain private array $items with no value-type annotation — PHP has no native
typed array, and xphp doesn't synthesise a @var Banana[] docblock for the
specialization. From level 6 PHPStan requires a value type on every iterable, so it
flags the untyped array property. This is the PHPStan pass being stricter than
xphp's own generic checks, not a generics error. (The element read
return $this->items[$i] is mixed, but PHPStan reports the property's missing
value type rather than the return.)
A single-value container avoids this entirely because its backing field can be a
real-typed private T (a private property is variance-exempt — see the
variance rules), so there is no untyped array at all. The
limitation is specific to the array-backed collection shape.
- Run the generic checks without the PHPStan pass:
xphp check src --no-phpstan(the generics themselves still validate). - Or analyse at level ≤5 (the missing-iterable-value-type rule is off there).
- Or scope a PHPStan ignore to the generated property in your
phpstan.neon(e.g.ignoreErrorson#Property .*::\$items type has no value type specified in iterable type array#).
The construction side is unaffected — the constructor parameter keeps its real type and is runtime-type-checked.
class Map<K, V> {
private array<K, V> $items; // rejected
}The
PHP RFC for generics
explicitly bans the array<K, V> syntax, and xphp follows.
The RFC took a clear position against expanding array into a
generic. xphp matches that boundary so source written today is
forward-compatible.
Use the T[] sugar (xphp-only but innocuous) or write array
directly:
class Map<K, V> {
private array $items; // works, no compile-time element check
}PHP array keys are int|string only, so $this->items[$k] = $v works
only when K is a string/int — it fatals for object keys. For a
container keyed on (or deduplicating) arbitrary objects, define your own
value-equality contract as a generic interface and bound on it, keying on
hashCode() internally. xphp special-cases nothing here — this is the same
pattern as a Comparable<T> ordering bound (see type bounds):
// Your library/app declares the contract — xphp ships no `Hashable`.
interface Hashable<T> {
public function hashCode(): int|string;
public function equals(T $other): bool;
}
final class Money implements Hashable { // bare marker — no LSP friction
public function __construct(private int $cents) {}
public function hashCode(): int|string { return $this->cents; }
public function equals(Money $other): bool { return $other->cents === $this->cents; }
}
// F-bounded: K must be hashable to its own kind.
class Map<K: Hashable<K>, V> {
private array $buckets = [];
public function set(K $k, V $v): void { $this->buckets[$k->hashCode()] = [$k, $v]; }
public function get(K $k): V { return $this->buckets[$k->hashCode()][1]; }
}Because a generic interface lowers to an empty marker (see ADR-0004), the
implementing class declares equals(Money $other) with its concrete type and
PHP imposes no signature constraint — exactly how a Comparable<T> implementer
writes compareTo(Money $other). The deduping/keying logic itself is ordinary
runtime code in your container; the bound just gives it a compile-time-checked
contract. (The container above is illustrative — xphp is a transpiler and ships
no collection types.)
Two .xphp files in the same source set both declare a generic
function (or class) with the same FQN:
Generic function template "App\identity" already declared (in
src/Util.xphp); duplicate declaration in src/Other.xphp.
(The same error fires for classes — the message uses
Generic template instead of Generic function template.)
Specialization keys every template by FQN. Two definitions with the same name would collide on the generated specializations and silently overwrite each other; xphp surfaces both source paths instead so you can see what's clashing.
Pick one canonical location. If you genuinely need the same name in two namespaces, put them in different namespaces — the FQN differs and they won't collide.
class Bad<T = Box | Other> {} // union not allowed
class Bad<T = ?Box> {} // nullable not allowed
class Bad<T = Box & Other> {} // intersection not allowedGeneric parameter `T` has an invalid default; only a single concrete
or generic type is allowed after `=` (no nullable or union shapes).
Defaults are substituted at instantiation time. Supporting union / intersection / nullable defaults would require a runtime decision on which arm to materialize, which isn't compatible with monomorphization's "one specialization per concrete arg tuple" model.
Pick a single default and let users override it explicitly with turbofish:
class Container<T = Box> {}
// users can still write: new Container::<Other> or new Container::<NullableThing>$x = new class<T> {
public T $item;
};Both the RFC and xphp forbid this — new class<T> { ... } is a
parse error.
Anonymous classes have no name to carry through specialization. The template-to-specialization machinery is keyed on the FQN; anonymous classes don't have one.
Lift to a named class:
class TempContainer<T> {
public T $item;
}
$x = new TempContainer::<User>();Running the transpiler on PHP 8.4 over a source file that uses PHP 8.5 syntax — for example the pipe operator:
$slug = $title |> trim(...) |> strtolower(...);Syntax error, unexpected '>'
xphp owns only the generic syntax; everything else is plain PHP. It
parses your source with nikic/php-parser configured for the host
PHP version (the one running the transpiler). On PHP 8.4 the lexer
can't tokenize 8.5-only syntax like |>, so the parse fails before
specialization even begins. There's nothing generic about the line —
it just never reaches the host parser's grammar.
Run the transpiler on a PHP version that can parse your syntax — e.g.
PHP 8.5 for the pipe operator. Plain (non-generic) code, including
newer-PHP syntax, passes straight through untouched. Note the emitted
PHP still requires a runtime that supports those features to execute;
the supported floor for xphp itself is PHP 8.4 (composer.json).
A derivation whose result type re-wraps the receiver's own type family in a growing form does not compile once that result is instantiated:
class ImmutableList<out E> {
// Seeds a map of sub-lists: the result type reintroduces the receiver's own
// family (ImmutableList) one level deeper.
public function groupBy<L>(callable $keyOf): ImmutableMap<L, ImmutableList<E>> { /* ... */ }
}
class ImmutableMap<K, out V> {
public function values(): OrderedCollection<V> { /* ... */ } // re-exposes the value as a list
}
// Aborts as soon as the result map is instantiated: specializing it walks its view
// bodies (values()/entries() construct deeper lists) even if you never call them.
$byKey = $list->groupBy::<string>($keyOf); // ❌ re-seeds List → Map → List → … without bound
$bucket = $byKey->get('a');Generic specialization did not converge (exceeded depth 16): a self-reintroducing
cycle grows without bound through App\ImmutableMap (…/ImmutableMap.xphp) and
App\ImmutableList (…/ImmutableList.xphp) — e.g. "…". Break the cycle: return a
non-self-reintroducing type from the re-exposing member (for example a
non-generic iterable), or split the derivation …
This is a by-design boundary, not a pending feature
(ADR-0020).
xphp monomorphizes — one class per instantiation
(ADR-0001) — so a member that
keeps producing a strictly-deeper instantiation of its own family has no fixed
point. Specialization discovers new instantiations structurally, from every
member of a specialized class — return types and the new expressions in method
bodies — including members you never call. So the result map's family-re-exposing
views (values()/entries()) re-seed the cycle on their own, and retyping a
signature without also changing the body that constructs the deeper value does not
stop it. Termination is guaranteed by the depth cap
(ADR-0006), which aborts with a
localized diagnostic naming every concrete class in the cycle and its source file.
(It surfaces at xphp compile; xphp check does not specialize, so it passes
green — compile to see the diagnostic.) Auto-erasing the cycle (a
dyn-style seam) is deferred, not built: every monomorphizing language provides
such an escape hatch (Rust's dyn Trait, JVM/HHVM erasure), but it must erase the
constructed value, not merely the type.
Give the grouped result a view-less type — one with no values()/entries()/
keys() member (and no body) that returns or constructs the receiver's family. A
result exposing only get(key)/count cannot re-seed the cycle, so the derivation
converges. In practice, host groupBy/associateBy as static generics on a plain,
non-variant helper returning that view-less result, rather than as members of the
collection — which also keeps the list template free of any map-returning member.
If you do want bucket iteration, expose it past a non-generic seam: a view typed
iterable/array whose body returns a plain array (no new ImmutableList::<…>), so
neither the signature nor the body re-introduces the family:
public function valuesList(): iterable { return array_values($this->entries); }
// foreach ($byKey->valuesList() as $bucket) { /* a real list at runtime; element type is mixed */ }The element type is mixed past that seam (re-narrow with instanceof where a typed
bucket is needed) — the same trade a dyn boundary makes. Or split the derivation
so the growing type is never reached through an unbounded chain.
A turbofish whose type argument is supplied by an enclosing generic scope — a
function type parameter or a class type parameter — cannot yet be specialized. Every
such shape is rejected with a loud compile error rather than emitted as runtime-fatal
code; the representative cases below are not exhaustive (a named free-function forward
grounded by an enclosing parameter, return identity::<T>($v) inside wrap<T>, is the
same class of shape and rejected the same way). Each may be lifted in a future version.
A generic closure grounded by an enclosing function type parameter:
function relay<S>(S $v): S
{
$inner = fn<I>(I $x): I => $x;
return $inner::<S>($v); // ❌ `S` is not concrete here
}Generic closure call `$inner::<S>(...)` cannot be specialized: its type argument(s)
are grounded only by an enclosing generic scope and are not concrete here …
A concrete inner closure turbofish, but written inside a generic function body:
function outer<T>(T $seed): int
{
$f = fn<U>(U $x): U => $x;
return $f::<int>(41); // ❌ the same call works at file scope, not here
}A method/static turbofish grounded by an enclosing class type parameter:
class Box<T>
{
public function make(T $v): T { return self::gen::<T>($v); } // ❌ `T` from the class
public static function gen<U>(U $x): U { return $x; }
}A generic turbofish/closure marker survived specialization into the emitted output …
[xphp.unspecialized_generic_leak]
Variable-turbofish and method-turbofish dispatch is call-site-driven: a site is
specialized only when its type arguments are concrete at that site. When the argument
comes from an enclosing type parameter it is still abstract when the inner site is
visited, so no concrete dispatch can be built; and the concrete-inner case (outer)
only fails because the closure sits inside a generic function body, which the current
dispatch pass does not re-enter per specialization. Left un-grounded, each would emit
PHP that names a non-existent type-parameter class (App\I, App\U, or a stripped
gen() method) and fatal on first use. Rather than emit that, xphp fails the build: the
closure form is caught at the source seam in both xphp check and xphp compile
(xphp.unspecialized_generic_closure); the two shapes that reach code generation are
caught by a compile-time backstop over the emitted output
(xphp.unspecialized_generic_leak). Grounding these shapes so they run is tracked
for a later release; today the guarantee is only that they never miscompile silently.
xphp check catches only the closure form. The two shapes that surface at code
generation (outer, Box::make, and the named free-function forward above) are caught
by the emit-time backstop, which xphp check does not run — it validates without
emitting. So check reports zero diagnostics for those, while compile rejects
them loudly. A CI pipeline that gates on xphp compile (or runs it after check) is
fully covered; one that gates on xphp check alone will see green on code that
compile will reject. This is a completeness gap in check, never a runtime-safety
hole: no fatal-able code is ever emitted.
Call the inner generic with an explicit concrete turbofish at a scope where the type is known, or lift it out of the enclosing generic scope:
$inner = fn<I>(I $x): I => $x;
echo $inner::<int>(41); // works at file / plain-function scope
function gen<U>(U $x): U { return $x; }
Box::useGen(gen::<int>(5)); // ground the generic where the type is concrete