PHP RFC: Extension Methods
- Version: 1.4
- Date: 2026-07-11
- Author: Holly Schilling, holly.a.schilling@outlook.com
- Status: Draft
- Implementation: https://github.com/php/php-src/pull/22635
- Discussion thread: https://news-web.php.net/php.internals/131861
- Voting thread: tbd
Introduction
This RFC proposes extension methods: the ability to declare additional methods for an existing class or interface from outside its definition, resolved at call time only when the class itself does not define the method. The block syntax follows Swift's extension blocks; the declared receiver variable follows C# 14's extension members (there is no $this in an extension body); the dispatch semantics follow C#'s rule that a type's own members always win.
The feature is pure dispatch sugar — extensions cannot add state, cannot access non-public members, and cannot override existing methods — so it composes with, rather than competes with, inheritance and traits. Typical uses: fluent helpers on final or vendor-owned classes (DateTimeImmutable, DOMElement, SDK value objects) without wrapper types or Closure::bind gymnastics.
extension \DateTimeImmutable $date { public function isWeekend(): bool { return in_array((int)$date->format('N'), [6, 7], true); } } var_dump((new DateTimeImmutable('2026-07-11'))->isWeekend()); // bool(true)
Targets are classes and interfaces. Extension methods on the built-in value types (“hello”->length()) are proposed in the companion Scalar Extension Methods RFC, which depends on this one: the dispatch model extends naturally to scalars, but binding a non-object receiver has a distinctly larger engine footprint and deserves its own review and vote.
Proposal
Syntax
extension TargetClassName $target { public function method(...): ReturnType { /* $target is the receiver instance */ } }
extensionis a contextual keyword, lexed asT_EXTENSIONonly when followed by another identifier (the same lookahead technique introduced forenum). Code usingextensionas a class, function, constant, or method name continues to work, includingclass extension extends Base {}.- The target may be any class or interface name, resolved against current
useimports at compile time. Fully qualified targets (extension \Vendor\Widget) are allowed. - The receiver is a declared variable, not
$this. Every extension block names its receiver (extension Target $t); each method receives the target instance in that variable, bound by value like a parameter, via a newZEND_RECV_RECEIVERopcode emitted after the parameter RECVs.$thisis a compile error inside extension bodies (“Cannot use $this in an extension method”) — the body holds the receiver the way any outside code holds an object reference, which is exactly why extension bodies see only the target's public API: the visibility rule falls out of the syntax instead of needing explanation. The receiver cannot be named$thisand cannot be shadowed by a parameter.static::is likewise a compile error (extension methods have no meaningful late-static-binding referent); abstract extension methods are rejected. - First-class callables work.
$obj->extMethod(...)produces a closure that binds the receiver at acquisition time and re-binds it into the declared variable on each invocation. - Reserved type names are rejected.
extension string { }and every other reserved type name is a compile error here (“Cannot extend reserved type ...”), rather than silently registering methods no object can match. The five value types become valid targets under the companion Scalar Extension Methods RFC. - Methods only. Properties, constants, and cases are compile errors (“Extension blocks may only declare methods”): object layout is fixed at link time and
default_properties_tableis immutable and shared under opcache. (Swift has the same stored-property restriction, for the same underlying reason.) - No magic methods. Method names with the reserved
__prefix are compile errors (“Extension blocks may not declare magic method ...”). Magic methods dispatch through dedicated class-entry slots and object handlers —newusesce->constructor, string casts use the cast handler, and so on — never through the resolution path where extension methods live, so an extension-declared magic method could never behave as one. This also forecloses any appearance of extensions injecting constructors or catch-alls into existing classes.
Semantics
- Compilation. An extension block compiles to a synthetic, final, uninstantiable class entry (reusing the anonymous-class pipeline). Its methods are ordinary methods whose scope is the synthetic CE. The receiver still travels through the completely normal method calling convention (the frame's This slot) and is bound into the declared variable at function entry — no new frame layout, and nothing in a body can observe the slot directly.
- Registration. A new
ZEND_BIND_EXTENSIONopcode registers the block's methods, keyed by lowercased target name, when the declaration executes. Visibility is therefore load-gated: an extension exists once its file has been included (require_once, Composerfiles), exactly like a function declaration. - Resolution.
zend_std_get_methodgains one fallback in its miss path: real method →__calltrampoline → extension lookup → undefined-method error. Consequences:- A class's own methods (any visibility) always shadow extensions.
- A class with
__callnever dispatches to extensions; the catch-all is the object's own declared behavior. - Matching walks the receiver's ancestry, then its interfaces — most-derived target wins.
- Interface targets act as call-time default implementations: conformers lacking the method dispatch to the extension; calls on the receiver variable inside the body resolve dynamically against the actual receiver, so behavior can be written in terms of the contract. Class-defined methods always shadow (Java default-method rule; PHP avoids Swift's static-dispatch surprise because receiver dispatch is always dynamic). Extensions do not satisfy interface contracts — abstract-method verification runs at class link time, before registration can be guaranteed; contract-fulfilling defaults are explicitly out of scope (see Future Scope).
- Conflicts. Two loaded extensions defining the same method for the same target: first registration wins; the prototype ignores duplicates, the final version should raise
E_WARNING(open issue: fatal instead?). - Indirect invocation. Reflection (
ReflectionClass::getMethods()) does not see extensions, matching C# (reflection over a type does not surface its extensions). However, any path that resolves through the object'sget_methodhandler —call_user_func([$obj, 'm']),is_callable([$obj, 'm']),method_exists($obj, 'm')— does currently resolve extensions in the prototype, with lexical gating applied from the nearest user frame. Whether to keep this (consistent dynamic dispatch) or restrict to direct$obj->m()calls is an open issue (see Open Issues).
Performance
Resolution runs exclusively on today's undefined-method error path; ordinary method calls are unaffected (inline-cache hit remains one compare + load). In phase 1, resolved extension functions are flagged ZEND_ACC_NEVER_CACHE, costing two registry hash lookups per extension call — comparable to a __call trampoline. Phase 2 (see Future Scope) enables the per-call-site polymorphic cache, which is sound because registration is monotonic within a request, bringing steady-state cost to that of a normal method call.
Backward Incompatible Changes
None intended. extension remains valid as a class, function, constant, and method name (contextual lexing, plus an extension extends/extension implements scanner carve-out and semi-reserved token status). One new opcode shifts ZEND_VM_LAST_OPCODE; extensions inspecting opcodes numerically must be rebuilt (routine for minor versions).
Proposed PHP Version(s)
Next PHP 8.x.
RFC Impact
To the Ecosystem
IDEs, language servers, and static analyzers must learn the extension Target { } syntax to offer completion and type-check extension calls; because blocks are fully static syntax (no runtime registration API), the required analysis is comparable to what tools already do for traits. Since reflection deliberately does not surface extensions (see Semantics), tooling must rely on that static analysis rather than runtime introspection. Auto-formatters need to treat extension blocks like class bodies. Userland libraries are unaffected unless they adopt the feature; stub generators (e.g. for IDE metadata) will want a convention for shipping extension declarations.
To Existing Extensions
None expected. zend_std_get_method changes only on its miss path; extensions overriding get_method entirely are unaffected (and consequently do not resolve extension methods, which is consistent with their opting out of standard dispatch).
To SAPIs
None beyond the engine changes; no SAPI-visible API changes. (Note the prototype registry's process-global lifetime under Open Issues, which is a prototype limitation rather than a proposal property.)
To Opcache
The synthetic CE persists like any anonymous class. The registry is per-request state (see Open Issues); ZEND_BIND_EXTENSION re-registers on each request, as ZEND_DECLARE_FUNCTION re-declares.
The persisted class image is never mutated at runtime. This is the invariant opcache reviewers will (rightly) probe: once a script is cached, its op_arrays, class entries, and literals live in shared memory that is immutable — and read-only under opcache.protect_memory=1, as CI runs. The design upholds it by construction: everything the feature needs to mark on the synthetic class (ZEND_ACC_NEVER_CACHE on each method, so call sites bypass the polymorphic inline cache) is applied at compile time, before persistence, and therefore travels with the cached image. Runtime registration only reads the persisted CE — the registry itself is engine-owned memory outside SHM. An earlier prototype revision set the flag at bind time and faulted (SIGBUS) under protect_memory; the current branch is verified against opcache.enable_cli=1 + opcache.protect_memory=1.
The tracing JIT already treats never-cached callees as unknown at call sites; the implementation additionally stops trace recording when entering a never-cached body (the same treatment as __call trampolines and property hooks), so extension-method calls stay interpreted, as intended for phase 1. The verified matrix includes opcache.jit=tracing with run-tests' aggressive hotness settings. Phase 2 of the performance plan requires teaching zend_jit_find_method_helper the fallback.
Open Issues
- Duplicate-registration diagnostics: warning vs fatal.
- Should
staticextension methods be allowed (Target::helper())? Deferred. - Interface diamond: two extensions supply the same method via unrelated interfaces of one receiver. Prototype: first match in the flattened
ce->interfacesorder (deterministic, arbitrary). Proposed: prefer the more-derived interface when one extends the other; otherwise throwErroron ambiguous resolution (Java's rule, minus compile-time timing). - Registry residence: must live in executor globals (per-request, ZTS-safe); the prototype uses a process global for simplicity and is single-request/CLI-only until moved. The per-request design must additionally be preload-aware:
opcache.preloadexecutes a declaring file's top level once at server start, so itsZEND_BIND_EXTENSIONnever re-runs in a request — and the function pointers captured at preload time are invalidated when preloading persists the script into shared memory. Extensions declared in preloaded files therefore need registry entries rebuilt from (or resident in) the persisted image, not merely per-request re-registration. - Indirect invocation: the
get_methodfallback makes extensions visible tocall_user_func/is_callable/method_exists(object form), unlike the original draft's direct-call-only rule. Decide: embrace (dynamic-dispatch consistency, gating still applies) or restrict (requires distinguishing call sites inget_method). - Conflict registration is first-wins per (target, method) even across named extensions, so a later named extension cannot supply a method already claimed by an earlier one, even to files that import only the later extension. Needs per-extension method lists with import-set-aware resolution.
Unaffected PHP Functionality
Ordinary method dispatch, __call/__callStatic, traits, inheritance, interfaces, closures, and the callable/reflection surfaces are unchanged. Any call that resolves today resolves identically with this RFC; extension lookup runs only where PHP currently raises “Call to undefined method”.
Future Scope
- Lexical visibility — now proposed separately as PHP RFC: Extension Method Visibility (use extension), prototyped as phase 2 on the same implementation branch: named blocks
extension Name on Target { }plususe extension Vendor\Name;, with per-file activation consulted from the nearest user frame. Anonymous blocks keep the global semantics proposed here. - Inline-cache and JIT fast path (phase 2 of the performance plan, above).
- Extension properties via hooks (computed only), piggybacking on property hooks.
- Reflection surface:
ReflectionClass::getExtensionMethods()and friends; synthesizedextension@file:lineframe names for debuggers and profilers. - Interface default methods (defaults declared inside the interface, present at link time) — a distinct feature; extensions deliberately do not attempt contract satisfaction due to load-order and opcache early-binding hazards.
Voting Choices
Primary vote requiring a 2/3 majority to accept the RFC (per the php/policies voting guidelines):
Add extension methods to PHP as outlined in the RFC? Yes / No
Patches and Tests
Implemented on the extension-methods-phase1 branch against master: contextual T_EXTENSION lexing, ZEND_AST_EXTENSION_DECL, ZEND_BIND_EXTENSION (opcode 212), the Zend/zend_extension_methods.{c,h} registry, and the zend_std_get_method fallback, plus opcache/JIT hardening (the never-cache flag is applied at compile time so the persisted class image is never written at runtime, and the tracing JIT does not follow into never-cached bodies). Verified on a debug build: the Zend test suite passes with zero failures; Zend/tests/extension_methods/ covers basic resolution, $this binding, real-method precedence, the undefined-method error path, and the magic-method and reserved-target rejections. Additional targeted tests are still needed for interface targets, __call precedence, and inheritance walks (currently verified manually). The companion scalar and visibility RFCs stack their branches on this one.
Implementation
After acceptance: link to the merged commit(s), PHP version, and documentation.
References
- Kotlin extension functions (lexically scoped precedent): https://kotlinlang.org/docs/extensions.html
Rejected Features
$thisas the extension receiver (versions ≤ 1.3, fully implemented before rejection). Binding the receiver to$thisread naturally but was wrong twice over. Semantically,$thiseverywhere else in PHP means “I am inside this object,” which made the public-only visibility rule feel like an arbitrary restriction needing apology; a declared receiver variable makes it self-evident. Mechanically,$thismust be reachable for an entire call, so extending it to non-object receivers (the companion scalar RFC) forced the receiver to live in the frame's This slot for the whole call — implementation experience produced three regressions in unrelated engine features (trampoline teardown, closure delayed-release, JIT'dFETCH_THIS), a catalog of bans (static::, generators, first-class callables), and a permanent JIT exclusion. The declared receiver reduces the slot to a one-instruction handoff and deleted nearly all of that surface. C# 14 reached the same conclusion for its extension members.
Changelog
- 1.4 (2026-07-10): the receiver is now a declared variable (
extension Target $t) —$thisis rejected in extension bodies and moves to Rejected Features with the implementation experience that killed it;static::rejected; first-class callables of extension methods now supported; abstract extension methods rejected. NewZEND_RECV_RECEIVERopcode binds the receiver at function entry.
- 1.3 (2026-07-09): reserved type names rejected as targets (“Cannot extend reserved type ...”); scalar extension methods split into the companion Scalar Extension Methods RFC — co-proposed and fully implemented on a dependent branch, but packaged separately because binding a non-object receiver has a distinctly larger engine footprint than this RFC's contained miss-path fallback, and deserves its own review and vote.
- 1.2 (2026-07-08): magic methods (the reserved
__prefix) rejected in extension blocks; registry open issue sharpened with the preload-awareness requirement. - 1.1 (2026-07-08): opcache/JIT hardening after CI: documented and enforced the never-mutate-persisted-image invariant (
ZEND_ACC_NEVER_CACHEmoved to compile time; tracing JIT stops at never-cached bodies); implementation verified underopcache.protect_memory=1andopcache.jit=tracing. - 1.0 (2026-07-07): working prototype landed; indirect-invocation behavior documented as an open issue; lexical visibility split out into the Extension Method Visibility RFC.
- 0.9: initial draft.