Table of Contents

PHP RFC: Extension Methods

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 */ }
}

Semantics

  1. 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.
  2. 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.
  3. 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).
  4. 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?).
  5. 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

  1. Duplicate-registration diagnostics: warning vs fatal.
  2. Should static extension methods be allowed (Target::helper())? Deferred.
  3. 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).
  4. 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.
  5. 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).
  6. 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

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

Rejected Features

Changelog