rfc:extension_methods_visibility

PHP RFC: Extension Method Visibility (use extension)

Introduction

The Extension Methods RFC gives extensions load-gated visibility: once a file declaring extension Target { } has executed, its methods are resolvable from anywhere in the request, exactly like a declared function. That is the right default for application-local helpers, but it is a poor fit for libraries: two Composer packages extending the same vendor class become visible to each other and to all application code, whether wanted or not, and the consumer has no way to see where a method came from.

This RFC adds the opt-in scoping layer: named extension blocks and use extension imports. A named extension's methods resolve only in files that imported it. This follows Kotlin's model (extension functions must be imported to be callable) rather than C#'s namespace-scoped variant, because PHP's natural scoping unit — like Kotlin's — is the file.

// vendor/acme/dom-kit/src/traversal.php
namespace Acme\DomKit;
 
extension DomTraversal on \DOMElement $el {
    public function firstByClass(string $class): ?\DOMElement { /* ... */ }
}
// app/render.php
use extension Acme\DomKit\DomTraversal;
 
$el->firstByClass('hero');   // resolves here
// app/other.php — no import
$el->firstByClass('hero');   // Error: Call to undefined method DOMElement::firstByClass()

Proposal

Syntax

Two additions to the base RFC's grammar:

  • Named declaration: extension Name on Target $recv { methods } (the receiver variable is required, as in the base RFC's anonymous form). Name follows class-declaration rules: an unqualified identifier, namespaced by the file's current namespace. on is a contextual identifier (not a reserved word) validated by the grammar; any other word in that position is a compile error. The anonymous form extension Target $recv { } remains valid and keeps its globally-visible semantics.
  • Import: use extension Vendor\Name; as a dedicated top-level use form. Multiple comma-separated names are allowed. Two deliberate restrictions:
    • No aliasing. use extension Foo as Bar; is a compile error. The import is activation, not renaming — there is no local identifier to alias, and method names must match the declaration regardless.
    • No group-use form. use extension Vendor\{A, B}; is not supported in this RFC. (Besides being marginal, admitting extension inside group-use braces measurably degrades existing parse-error messages by overflowing the parser's expected-token reporting; see Open Issues.)

Semantics

  1. Imports are per compilation unit and position-sensitive. use extension makes the named extension visible to code compiled after it in the current file — exactly the rule every other use import follows. Each eval() chunk is its own compilation unit: it may import independently, and its imports do not leak into the surrounding file. (One lenient corner: a file's top-level code is a single unit and receives the file's full import set; functions and methods snapshot the set as of their declaration point. PSR-12 places imports at the top of the file, where the distinction never arises.)
  2. Resolution is gated by the executing frame. When the base RFC's fallback finds a method belonging to a named extension, it is returned only if the nearest user-code frame's file has activated that extension; otherwise the walk continues (a parent class or interface may still supply a visible method). Consequences:
    • Visibility follows where the code was written, not who called it: a helper function defined in an importing file can use the extension even when invoked from a non-importing file.
    • Indirect calls (call_user_func et al., inherited from the base RFC's open issue) are gated from the nearest user frame, so an internal-function trampoline does not widen visibility.
  3. The declaring position imports itself. A named extension is visible to its own methods and to code below its declaration in the declaring file, without a self-import (matching Kotlin: same-file extensions need no import).
  4. Scalar targets gate identically (if the Scalar Extension Methods RFC is accepted). A named extension on a built-in value type (extension Str on string) is gated exactly like a class-targeted one — which is what makes competing userland scalar APIs coexist: each file imports the string vocabulary it wants. The gating test (scalar_named_gating.phpt) ships with this RFC's branch.
  5. Importing is not loading — the same rule as every other use statement. A plain use A\B; does not load class B either; it only affects name resolution, and the class is loaded later, on first use, by the autoloader. use extension follows the first half of that model: it only grants visibility, and is inert until the extension's declaring file has executed (require, Composer files — the base RFC's load-gating). What it does not yet do is the second half — trigger autoloading on first use: calling a method of an imported-but-never-loaded extension is simply the ordinary “Call to undefined method” error, and no autoloader is consulted. Two consequences worth stating explicitly: importing an extension that never loads is not an error (like an unused use import), and the relative order of import and load does not matter — resolution works as soon as both have happened. So, to answer the recurring question directly — “do I need the use extension statement and a require_once?” — yes, both must happen, but usually not both by you. Loading is the declaring package's job: in a Composer project it lists its extension files under its autoload.files directive, which Composer executes once at bootstrap (exactly how function files ship today), so consumer code writes only the use extension line. A manual require_once is needed only when no bootstrap loads the declaring file for you — the same situation in which a plain function from that file would also be undefined. And this answer has an expiry date: autoload triggering is Future Scope, and the ecosystem half of it is already staged — composer/class-map-generator#43 (draft) teaches Composer's class-map generator to map named extension declarations to their files exactly as it maps classes. Once that PR and the engine-side trigger are merged, no require_once — and no package-side files entry — is required at all: the use extension line alone both grants visibility and, on first use, loads the declaring file, because the named form exists precisely so extension names are PSR-4 addressable.
  6. Precedence is unchanged from the base RFC: real method → __call → extension lookup. Anonymous (global) and named (imported) extensions live in one registry; within a target, the first registered method still wins (but see Open Issues).

Implementation notes

ZEND_BIND_EXTENSION gains the lowercased fully-qualified extension name as the literal following the target-name literal (consecutive-literal pattern, presence flagged via extended_value); registry entries carry their extension name. Import sets are a compile-time property of the zend_op_array itself: a small packed table of lc names, shared (copy-on-write per import statement) between the op_arrays of one file, snapshotted by pointer as each function is compiled, and persisted by opcache exactly like attributes. Resolution reads the set directly off the nearest user frame's op_array — there is no global activation state of any kind. The grammar factors the shared T_EXTENSION prefix of the anonymous and named forms into one nonterminal (an LALR requirement, not a design choice).

Performance

Gating adds, on the extension-resolution slow path only, one walk to the nearest user frame plus a linear scan of that op_array's import set (import sets are a handful of interned names; the scan is cheaper than a hash lookup). Ordinary method calls remain untouched, and there is zero per-request setup cost — imports are read-only compiled data. For the future inline-cache phase, this design is load-bearing: call-site caches are per-op_array, an op_array belongs to exactly one file, and its import set is immutable after compilation, so a cached resolution can never leak across visibility boundaries — op_array-resident imports make call-site caching inherently scope-correct.

Backward Incompatible Changes

None beyond the base RFC. The named form and use extension were syntax errors under the base RFC (and under current PHP). use extension; — importing a class literally named extension — continues to parse as a class import, because T_EXTENSION is only lexed when followed by another identifier.

Proposed PHP Version(s)

Same release as the base Extension Methods RFC; this RFC is only meaningful if that one is accepted.

RFC Impact

To the Ecosystem

Tooling that learned the base RFC's extension blocks needs two additions, both static: the named declaration form, and per-file visibility driven by use extension lines. The latter is the same shape of analysis IDEs and static analyzers already perform for use imports, and it makes extension-call resolution more tractable for tools than the base RFC alone: instead of “any loaded extension might apply”, a file's candidate set is enumerable from its imports. Analyzers should flag calls to named-extension methods in files that did not import them — mirroring the engine's behavior at analysis time.

To Existing Extensions

None; the changes are confined to the extension-methods registry and its resolution path.

To SAPIs

None. (Import visibility carries no per-request or per-process state at all; only the base RFC's registry retains the prototype's process-global lifetime caveat.)

To Opcache

Import sets are part of the compiled artifact, so opcache correctness falls out by construction. Because the set lives in the zend_op_array (like attributes, literals, or static variables), it is persisted to shared memory with the script, serialized by file_cache, and preserved by opcache.preload — the three modes in which a file executes without being recompiled. There is no side-band activation state to restore, no per-request replay work, and nothing keyed by filename identity (symlinks, phars, and same-file-different-path cannot split visibility). The cost is one pointer field on zend_op_array (an ABI change for extensions inspecting op_arrays, routine for a minor version) plus contained additions to zend_persist and zend_file_cache following the existing attributes pattern.

Two designs were considered and rejected (see Rejected Features): a runtime activation opcode repopulating per-request state — disqualified because preloaded files' top-level code never executes in a request, recreating the very compile-versus-execute split this design must eliminate — and opcache-persisted side tables replayed on cache load, which touch every load path (SHM, file_cache, preload) for strictly less benefit.

This RFC preserves the base RFC's invariant that the persisted script image is never mutated at runtime. Bind-time registration and call-time gating only read the cached image (the CE, the target-name/extension-name literal pair, and the import set); the only writable state — the registry with its provenance entries — lives in engine-owned memory outside shared memory. Because the named form stores the extension name as the literal following the target name, opcache's literal-compaction pass must relocate the pair together; the implementation registers ZEND_BIND_EXTENSION with the compaction pass exactly as ZEND_INIT_METHOD_CALL registers its paired method-name literals. Verified against opcache.enable_cli=1 + opcache.protect_memory=1, opcache.jit=tracing, opcache.file_cache_only round-trips across processes, and opcache.preload.

Open Issues

  1. Import-set-aware conflicts. Registration is first-wins per (target, method) across all extensions. If two named extensions supply the same method for the same target, the later one loses even in files that import only the later one. Correct behavior needs per-extension method lists consulted against the caller's import set, with ambiguity (two imported extensions supplying one method) becoming an Error at the call site.
  2. Group-use form. Excluded here; admitting it later requires either accepting degraded parse-error messages in group-use states or custom syntax-error reporting.
  3. Re-export. Should a file be able to re-export its imports (Kotlin has no equivalent; ES modules do)? Currently: no, every consumer imports explicitly.

Unaffected PHP Functionality

Everything listed in the base RFC, plus: anonymous extension blocks keep their exact base-RFC semantics — this RFC is purely additive, and code that never writes on or use extension cannot observe it.

Future Scope

  • Autoload triggering: resolve an unloaded use extension Vendor\Name through the class autoloader at first use, making named extensions fully PSR-4 addressable. Requires deciding what artifact the autoloader maps the name to (the synthetic CE registered under its extension name is the leading candidate). The tooling side needs no waiting: PSR-4 itself requires no amendment (its “other similar structures” wording covers extensions, as it did enums), and Composer's class-map generator already has a draft PR — composer/class-map-generator#43 — mapping named extension declarations, held as draft only until this RFC family's grammar is final. Only the engine-side trigger remains.
  • Inline-cache and JIT fast path for gated resolution (per-op_array caches, as noted under Performance).
  • Diagnostics: a “did you forget use extension Vendor\Name?” hint on the undefined-method error when a loaded-but-unimported extension would have matched.

Voting Choices

Primary vote requiring a 2/3 majority to accept the RFC (per the php/policies voting guidelines); void if the base Extension Methods RFC is declined:

Add named extension blocks and use extension visibility as outlined in the RFC? Yes / No

Patches and Tests

Implemented on the extension-methods branch as a single commit atop the extension-methods-scalars branch (which in turn stacks on the base RFC's extension-methods-phase1) — the diff between the two adjacent branches is exactly this proposal (including its opcache literal-compaction support for the named form's paired literals). Verified on a debug build with the full Zend test suite green. Zend/tests/extension_methods/ covers: named declaration with declaring-file auto-activation (named_basic.phpt), imports (use_extension.phpt), gating across files, helper-function frames, and independent eval() scopes (lexical_gating.phpt), and the on/alias syntax errors (named_syntax_errors.phpt).

Implementation

After acceptance: link to the merged commit(s), PHP version, and documentation.

References

Rejected Features

  • Aliasing extension imports (use extension Foo as Bar) — imports activate, they do not name; there is nothing an alias would refer to.
  • Namespace-scoped visibility (C# model) — PHP namespaces span files and vendors; the file is the unit a reader can actually see, and it is the unit call-site caches naturally respect.
  • Runtime activation opcode (per-request executor-globals table repopulated by an op emitted for each use extension) — it would make one use form a runtime construct, add per-request setup work of exactly the kind opcache exists to eliminate, and — decisively — break under opcache.preload: preloaded files' top-level code never executes in a request, so their activations would silently never fire, reproducing the original compile-versus-execute bug one layer down.
  • Opcache-persisted activation side tables (import sets stored alongside the cached script and replayed on load) — requires cooperation from every load path (SHM hit, file_cache deserialization, preload) and keeps activation keyed by filename identity; op_array-resident sets achieve the same persistence through machinery opcache already has, with none of the replay surface.

Changelog

  • 1.4 (2026-07-11): referenced the Composer class-map generator draft PR (composer/class-map-generator#43) in the loading discussion, Future Scope, and References; stated the end state of the loading story — once that PR and the engine-side autoload trigger land, use extension alone suffices and no require_once or autoload.files entry is needed.
  • 1.3 (2026-07-10): named form updated for the base RFC's declared receiver variable (extension Name on Target $recv); clarified that importing is not loading, mirroring the semantics of every other use statement (reader feedback).
  • 1.2 (2026-07-08): resolved the activation-persistence open issue — import sets are now a compile-time property of the zend_op_array, persisted by opcache (SHM, file_cache, preload all verified); imports became position-sensitive like other use statements; rejected the runtime-activation-opcode and persisted-side-table designs (preload argument recorded under Rejected Features).
  • 1.1 (2026-07-08): documented the shared-memory invariant inherited from the base RFC and the literal-compaction requirement for the named form's paired literals; implementation verified under opcache.protect_memory=1 and opcache.jit=tracing.
  • 1.0 (2026-07-07): initial draft, written alongside the phase-2 prototype.
rfc/extension_methods_visibility.txt · Last modified: by thecodelorax