====== 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: [[rfc:extension_methods|PHP RFC: Extension Methods]] * Implementation: https://github.com/hollyschilling/php-src/tree/extension-methods * Discussion thread: tbd * Voting thread: tbd ===== Introduction ===== The [[rfc:extension_methods|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 ==== - **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.) - **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. - **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 [[rfc:extension_methods_scalars|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 ''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: both halves of the fix exist. The ecosystem half is [[https://github.com/composer/class-map-generator/pull/43|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 [[rfc:extension_methods_autoload|Extension Method Autoloading RFC]], implemented on its own branch. With both accepted, 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. - **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 ''Error'' at the call site. This issue now gates future work: the companion [[rfc:extension_methods_autoload|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 [[rfc:extension_methods_autoload|Extension Method Autoloading RFC]] resolves an unloaded ''use extension Vendor\Name'' through 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 — [[https://github.com/composer/class-map-generator/pull/43|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 [[https://github.com/php/policies/blob/main/feature-proposals.rst#voting-phase|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 [[https://github.com/hollyschilling/php-src/tree/extension-methods|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'') — [[https://github.com/hollyschilling/php-src/compare/extension-methods-scalars...extension-methods|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: [[rfc:extension_methods|PHP RFC: Extension Methods]] * Companion (depends on this RFC): [[rfc:extension_methods_autoload|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 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.5 (2026-07-11): autoload triggering moved from Future Scope to the companion [[rfc:extension_methods_autoload|Extension Method Autoloading RFC]] (implemented on the ''extension-methods-autoload'' branch); 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 ([[https://github.com/composer/class-map-generator/pull/43|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.