====== 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 [[rfc:extension_methods_scalars|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 */ }
}
* ''extension'' is a **contextual keyword**, lexed as ''T_EXTENSION'' only when followed by another identifier (the same lookahead technique introduced for ''enum''). Code using ''extension'' as a class, function, constant, or method name continues to work, including ''class extension extends Base {}''.
* The target may be any class or interface name, resolved against current ''use'' imports 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 new ''ZEND_RECV_RECEIVER'' opcode emitted after the parameter RECVs. ''$this'' is 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 ''$this'' and 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 [[rfc:extension_methods_scalars|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_table'' is 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 — ''new'' uses ''ce->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_EXTENSION'' opcode 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'', Composer ''files''), exactly like a function declaration.
- **Resolution.** ''zend_std_get_method'' gains one fallback in its miss path: real method → ''__call'' trampoline → **extension lookup** → undefined-method error. Consequences:
* A class's own methods (any visibility) always shadow extensions.
* A class with ''__call'' never 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's ''get_method'' handler — ''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 ''static'' extension 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->interfaces'' order (deterministic, arbitrary). Proposed: prefer the more-derived interface when one extends the other; otherwise throw ''Error'' on 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.preload'' executes a declaring file's top level once at server start, so its ''ZEND_BIND_EXTENSION'' never 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_method'' fallback makes extensions visible to ''call_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 in ''get_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 [[rfc:extension_method_visibility|PHP RFC: Extension Method Visibility (use extension)]], prototyped as phase 2 on the same implementation branch: named blocks ''extension Name on Target { }'' plus ''use 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; synthesized ''extension@file:line'' frame 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 [[https://github.com/php/policies/blob/main/feature-proposals.rst#voting-phase|php/policies voting guidelines]]):
> Add extension methods to PHP as outlined in the RFC? Yes / No
===== Patches and Tests =====
Implemented on the [[https://github.com/hollyschilling/php-src/tree/extension-methods-phase1|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 =====
* Swift extensions: https://docs.swift.org/swift-book/documentation/the-swift-programming-language/extensions/
* C# extension methods: https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods
* Kotlin extension functions (lexically scoped precedent): https://kotlinlang.org/docs/extensions.html
===== Rejected Features =====
* **''$this'' as the extension receiver** (versions ≤ 1.3, fully implemented before rejection). Binding the receiver to ''$this'' read naturally but was wrong twice over. //Semantically//, ''$this'' everywhere 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//, ''$this'' must 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'd ''FETCH_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'') — ''$this'' is 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. New ''ZEND_RECV_RECEIVER'' opcode 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 [[rfc:extension_methods_scalars|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_CACHE'' moved to compile time; tracing JIT stops at never-cached bodies); implementation verified under ''opcache.protect_memory=1'' and ''opcache.jit=tracing''.
* 1.0 (2026-07-07): working prototype landed; indirect-invocation behavior documented as an open issue; lexical visibility split out into the [[rfc:extension_method_visibility|Extension Method Visibility RFC]].
* 0.9: initial draft.