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.
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 {}.use imports at compile time. Fully qualified targets (extension \Vendor\Widget) are allowed.$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.$obj->extMethod(...) produces a closure that binds the receiver at acquisition time and re-binds it into the declared variable on each invocation.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.default_properties_table is immutable and shared under opcache. (Swift has the same stored-property restriction, for the same underlying reason.)__ 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.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.zend_std_get_method gains one fallback in its miss path: real method → __call trampoline → extension lookup → undefined-method error. Consequences:__call never dispatches to extensions; the catch-all is the object's own declared behavior.E_WARNING (open issue: fatal instead?).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).
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.
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).
Next PHP 8.x.
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.
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).
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.)
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.
static extension methods be allowed (Target::helper())? Deferred.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).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.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).
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”.
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.ReflectionClass::getExtensionMethods() and friends; synthesized extension@file:line frame names for debuggers and profilers.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
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.
After acceptance: link to the merged commit(s), PHP version, and documentation.
$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.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.__ prefix) rejected in extension blocks; registry open issue sharpened with the preload-awareness requirement.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.