PHP RFC: Extension Method Visibility (use extension)
- Version: 1.5
- Date: 2026-07-07
- Author: Holly Schilling, holly.a.schilling@outlook.com
- Status: Draft
- Depends on: PHP RFC: Extension Methods
- Implementation: https://github.com/hollyschilling/php-src/tree/extension-methods
- Discussion thread: tbd
- Voting thread: tbd
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).Namefollows class-declaration rules: an unqualified identifier, namespaced by the file's current namespace.onis a contextual identifier (not a reserved word) validated by the grammar; any other word in that position is a compile error. The anonymous formextension Target $recv { }remains valid and keeps its globally-visible semantics. - Import:
use extension Vendor\Name;as a dedicated top-leveluseform. 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, admittingextensioninside group-use braces measurably degrades existing parse-error messages by overflowing the parser's expected-token reporting; see Open Issues.)
Semantics
- Imports are per compilation unit and position-sensitive.
use extensionmakes the named extension visible to code compiled after it in the current file — exactly the rule every otheruseimport follows. Eacheval()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.) - 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_funcet 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.
- 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).
- 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. - Importing is not loading — the same rule as every other
usestatement. A plainuse A\B;does not load classBeither; it only affects name resolution, and the class is loaded later, on first use, by the autoloader.use extensionfollows the first half of that model: it only grants visibility, and is inert until the extension's declaring file has executed (require, Composerfiles— 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 unuseduseimport), 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 theuse extensionstatement and arequire_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 itsautoload.filesdirective, which Composer executes once at bootstrap (exactly how function files ship today), so consumer code writes only theuse extensionline. A manualrequire_onceis 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: both halves of the fix exist. The ecosystem half is composer/class-map-generator#43 (draft), which teaches Composer's class-map generator to map named extension declarations to their files exactly as it maps classes; the engine half is the companion Extension Method Autoloading RFC, implemented on its own branch. With both accepted, norequire_once— and no package-sidefilesentry — is required at all: theuse extensionline 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. - 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
- 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
Errorat the call site. This issue now gates future work: the companion autoloading RFC's target-hint optimization requires load order to be semantically inert, which only ambiguity-is-an-Error provides — first-wins plus autoloading would otherwise let load order (and eventually generated metadata) select conflict winners. - Group-use form. Excluded here; admitting it later requires either accepting degraded parse-error messages in group-use states or custom syntax-error reporting.
- 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 has graduated from future scope to a proposal: the companion Extension Method Autoloading RFC resolves an unloaded
use extension Vendor\Namethrough the class autoloader at first use, registering the synthetic CE in the class table under its extension name (the “leading candidate” design, now implemented). 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 — held as draft only until this RFC family's grammar is final. - 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
- Base RFC: PHP RFC: Extension Methods
- Companion (depends on this RFC): PHP RFC: Extension Method Autoloading
- Kotlin extension imports (per-file visibility precedent): https://kotlinlang.org/docs/extensions.html#scope-of-extensions
- C# extension methods (namespace-scoped alternative): https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods
- Composer class-map support for named extensions (draft PR): https://github.com/composer/class-map-generator/pull/43
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 oneuseform a runtime construct, add per-request setup work of exactly the kind opcache exists to eliminate, and — decisively — break underopcache.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_cachedeserialization, 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.5 (2026-07-11): autoload triggering moved from Future Scope to the companion Extension Method Autoloading RFC (implemented on the
extension-methods-autoloadbranch); loading discussion and References updated accordingly; noted under Open Issues that first-wins conflict resolution now gates the companion RFC's planned load optimizations. - 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 extensionalone suffices and norequire_onceorautoload.filesentry 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 otherusestatement (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 otherusestatements; 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=1andopcache.jit=tracing. - 1.0 (2026-07-07): initial draft, written alongside the phase-2 prototype.