rfc:extension_methods_autoload

PHP RFC: Extension Method Autoloading

Introduction

The visibility RFC draws a line it inherits from every other use statement: importing is not loading. use extension Vendor\Name; grants visibility, but the declaring file must still be executed by something — a require_once, or the declaring package's Composer autoload.files entry. This RFC removes that residue: an imported-but-unloaded named extension is resolved through the ordinary class autoloader at first use, exactly as an unloaded class is. The use extension line becomes the only line a consumer writes:

// vendor/acme/dom-kit/src/DomTraversal.php  (PSR-4 / classmap addressable)
namespace Acme\DomKit;
 
extension DomTraversal on \DOMElement $el {
    public function firstByClass(string $class): ?\DOMElement { /* ... */ }
}
// app/render.php — nothing else loads DomTraversal.php
use extension Acme\DomKit\DomTraversal;
 
$el->firstByClass('hero');   // first use: autoloaded, resolved, called

The ecosystem half of this pipeline already exists: composer/class-map-generator#43 (draft) teaches Composer's class-map generator to map named extension declarations to their files. PSR-4 itself needs no amendment — its “classes, interfaces, traits, and other similar structures” wording covers extension declarations, as it covered enums. This RFC supplies the engine half.

Proposal

Named extensions become class-table symbols

The visibility RFC left open “what artifact the autoloader maps the name to.” This RFC answers: the named extension's synthetic class entry, registered in the class table under the extension's own name at bind time (when the declaring statement executes). Consequences, stated plainly:

  • class_exists('Vendor\Name') is true once the declaration has executed — and class_exists('Vendor\Name', true) triggers autoloading, exactly as for a class. This is deliberate: “is this extension available?” now has the same answer mechanism as every other symbol.
  • Extension names share the class symbol namespace. Declaring an extension under a name already taken by a class, interface, trait, enum, or another extension is a fatal error (“Cannot declare extension %s, because the name is already in use”), symmetric with class redeclaration. One name, one symbol — the property every autoloader (and every reader) already assumes.
  • The symbol is inert as a class. The CE is final and uninstantiable: new Vendor\Name throws (“Cannot instantiate abstract class Vendor\Name” — see Open Issues for the message), extends fails against final, and the CE carries the extension's real qualified name, which is what reflection and diagnostics display.
  • Anonymous extension blocks are unchanged: they declare no symbol, participate in no autoloading, and keep their base-RFC semantics.

Autoload triggering

Resolution stays lazy — the same moment a class is autoloaded is the moment an extension is: first use. When extension-method resolution misses (object or scalar receiver), the engine takes each name in the calling code's import set that has no class-table entry and hands it to the class autoloader (zend_lookup_class_ex, i.e. the spl_autoload chain), then retries the lookup once. Semantics:

  1. The autoloader receives the original-case name as written in the use extension statement (or in the declaring position, for self-imports). Import sets store the original spelling alongside the lowercased gating key, because PSR-4 file resolution is case-sensitive.
  2. At most one attempt per name per request. An imported-but-unloadable extension is not an error — an unused import is legal, mirroring use of a class that is never mentioned — so, unlike the class case (where failure throws and naturally stops repetition), nothing else would bound re-attempts. A per-request negative cache does. No attempt is consumed while no autoloader is registered at all, so bootstrap order cannot permanently poison a name.
  3. Autoloader exceptions propagate. If the loader throws, the call site reports that exception, not “Call to undefined method” — matching class autoloading.
  4. Autoloading never widens visibility. Loading and gating remain orthogonal: the loaded extension resolves only in frames whose op_array imported it, exactly per the visibility RFC. A non-importing file's miss can still trigger the load (its own imports only), and an importing file's successful load grants nothing to anyone else.
  5. Functions that resolve methods through the engine's method lookup (method_exists, is_callable on objects) can consequently trigger the autoload from an importing frame — the same way class_exists($n, true) triggers class loading. Since these already observe extension methods (base RFC open issue), this adds no new observability, only earlier loading.

Why the import set is the resolution key

Note what the trigger does not do: it never maps the missing method to a specific extension. That mapping (method name + receiver type → extension name) does not exist anywhere before the declaring file executes — a PHP source file's contents are not queryable without running it, and the class map knows names→files, nothing more. So the engine uses the only sound key available: the calling file's import set. All unloaded imports are loaded on a miss, then the lookup retries once.

This is precisely where extension autoloading escapes the fate of function autoloading. An unqualified function call has an unbounded candidate space (current namespace, global fallback, any loader's guess); an extension method call, by the visibility RFC's own rules, can only be satisfied by an extension the file explicitly imported by fully-qualified name. The candidate list is finite, explicit, and sitting in the op_array — typically a handful of names.

The trade-off is stated honestly: the engine over-loads. A miss that turns out to be a genuine typo loads all of the file's unloaded imports before the undefined-method error is raised. Three bounds keep this cheap: it happens on the error/first-touch path only; each name is attempted at most once per request; and extension declaring files are declaration-only by construction. The end state after the first miss is deterministic — the file's imports are simply all loaded, the same world its autoload.files entries would have created at bootstrap, arrived at lazily (never earlier, often never). One coupling worth recording: the visibility RFC's group-use and re-export open issues exist to grow import sets cheaply; accepting either later fattens this load batch, so those decisions and this trigger are linked.

The end state for consumers

With this RFC accepted and Composer support merged, the loading story of the visibility RFC's FAQ (“do I need use extension and a require_once?”) collapses to its final form: no. The package maps its extension files via PSR-4/classmap as it maps classes; the consumer writes use extension Vendor\Name; and calls methods. autoload.files entries and manual require_once become what they are for classes today: unnecessary.

Backward Incompatible Changes

None for code without extensions. Within the extension-methods family (not yet released, so these are proposal-internal changes):

  • Redeclaring a named extension — or naming one after an existing class — was silently tolerated by the registry's first-wins rule; it is now a fatal error, consistent with classes.
  • class_exists() answers true for loaded named extensions.

Proposed PHP Version(s)

Same release as the base Extension Methods RFC; meaningful only if the visibility RFC is accepted (anonymous extensions have no names to load).

RFC Impact

To the Ecosystem

This is the RFC Composer has been waiting on: composer/class-map-generator#43 maps extension Name on Target $recv { } declarations into the class map and is held as draft only until this family's grammar is final. Static analyzers gain a stronger property: an unimported extension call is now provably unresolvable (before, a global require anywhere could have loaded an anonymous extension), and IDE “missing import” quick-fixes can offer use extension exactly as they offer use.

To Existing Extensions

One new executor-global (the per-request attempted-names table, lazily allocated) — an ABI change of the routine minor-version kind. The class-table entries for named extensions are alias-typed (IS_ALIAS_PTR), the same mechanism class_alias() uses, so C code iterating the class table must already tolerate them.

To SAPIs

None. The negative cache is request-scoped and resets with the executor.

To Opcache

Import sets simply carry one more interned string per import (the original-case name), persisted through the exact machinery the visibility RFC already uses — the persistence code iterates elements and needed no changes. The synthetic CE's name and uninstantiable flag are set at compile time, preserving the family invariant that the persisted script image is never mutated at runtime; the class-table binding at execution writes only to the per-request class table, never to shared memory. Verified green: full Zend suite under opcache.protect_memory=1 and under tracing and function JIT.

Open Issues

  1. The instantiation error message. new Vendor\Name currently reports “Cannot instantiate abstract class” (the CE reuses the abstract-class instantiation guard). A bespoke “Cannot instantiate extension %s” needs either a spare CE flag or an extension-CE discriminator; cosmetic, but worth fixing before final vote.
  2. Reflection classification. ReflectionClass works today (getName() returns the extension name, isInstantiable() is false), but the synthetic CE also reports isAnonymous() === true — an artifact of its construction. The family's deferred reflection surface (ReflectionExtension-style first-class API) should subsume this.
  3. Negative-cache staleness. A loader registered after a failed attempt for a name will not be re-consulted for it that request (registering loaders before first use is the universal practice, and Composer's bootstrap guarantees it — but the asymmetry with classes, which retry every time because failure throws, deserves a sentence in the docs).

Unaffected PHP Functionality

Class autoloading itself is untouched — extensions ride the existing chain; no new autoloader hook, no new callback signature, no changes to spl_autoload_register. Anonymous extensions, gating semantics, and everything listed in the prior RFCs' unaffected sections.

Future Scope

  • Target-hint accelerated loading. If extension-heavy frameworks materialize, the load-all-imports batch can be narrowed without new syntax and without trusting external metadata for dispatch. Composer's class-map generator already parses the declaration's target token (composer/class-map-generator#43); it can emit a name → target map from that single source of truth, and Composer's bootstrap pushes it into the engine through a small registration API (the spl_autoload_register idiom — there is deliberately no query protocol in the autoload chain, so hints must be pushed, not fetched). At miss time the engine loads ancestry-matching (and unhinted) imports first, and the remainder only if the miss persists: a stale or missing hint costs a fallback pass, never correctness. Three design constraints are recorded now, in advance: (1) omit on uncertainty — a textual generator cannot fully reimplement PHP's name resolution for aliased targets, so it must emit a hint only when the target resolves trivially, and an absent hint means always-candidate; (2) no per-request copy — registration must hold a reference to the opcache-shared array (or a lazy provider), not rebuild an engine table each request, per this family's own rejection of per-request setup work; (3) prerequisite: import-set-aware conflict resolution (visibility RFC Open Issue 1) must land first — under first-wins registration, hint-driven load order would let generated metadata select conflict winners, and only ambiguity-is-an-Error makes load order semantically inert.
  • Moving the method registry into executor globals (per-request lifetime) — this RFC makes the class-table half of extension state per-request already; the registry keeps the prototype's process-global caveat.
  • A use extension quick-fix diagnostic (“did you forget to import?”) remains as proposed in the visibility RFC.

Voting Choices

Primary vote requiring a 2/3 majority (per the php/policies voting guidelines); void if the visibility RFC is declined:

Autoload named extensions through the class autoloader as outlined in the RFC? Yes / No

Patches and Tests

Implemented on the extension-methods-autoload branch, stacked on the visibility RFC's extension-methods branch — the diff between the two branches is exactly this proposal. Zend/tests/extension_methods/ adds: first-use autoload with a one-invocation loader (autoload_basic.phpt), the scalar dispatch path (autoload_scalar.phpt), class_exists/instantiation/reflection of the class-table symbol (autoload_class_symbol.phpt), name collision fatality (autoload_collision.phpt), negative caching of unloadable imports (autoload_negative_cache.phpt), autoloader exception propagation on both object and scalar paths (autoload_throw.phpt), and gating unaffected by autoloading (autoload_gating.phpt). Full matrix green: plain, opcache.protect_memory=1, tracing and function JIT.

Implementation

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

References

Rejected Features

  • A dedicated extension-autoloader hook (spl_autoload_register_extension or a new callback signature) — a second autoload chain doubles the bootstrap surface of every framework for zero expressive gain; the class chain already delivers name→file resolution, and Composer needs one map, not two.
  • Eager loading at the use extension statement — would make one use form a runtime construct (the visibility RFC's rejected activation opcode, one layer up), punish speculative imports, and break the position-independence consumers expect from imports.
  • A registry-only symbol (no class-table entry) — a parallel name table would need its own collision policy against classes, its own class_exists analog, and its own autoload recursion guards; the class table already has all three, and sharing it is what makes the collision rule enforceable rather than advisory.
  • Import-site target claims (use extension Vendor\Name for Target;) — proposed to narrow the load-all-imports batch, but the claim is unverifiable at the moment it steers loading: the declaration is, by definition, not loaded yet, so the claim can only be checked after the load it was meant to optimize. A claim stale in the narrow direction — the library widened on DOMElement to on DOMNode, or the consumer named the class where the extension targets its interface — makes the engine skip a load it needed, producing a wrong “Call to undefined method” for a correctly imported, correctly published extension: silent misbehavior, the worst failure class. Preventing it requires a load-time Error on mismatch, which turns every import into a contract on the library's target choice and breaks consumers whenever a library performs the good refactor of widening its target. And since a claim can be wrong, correctness still requires the load-the-rest fallback — the syntax only reorders work the engine must be able to do anyway. The target-hint design in Future Scope achieves the same filtering from the declaration itself, with no consumer-side contract.
  • Method-indexed autoload metadata as a resolution key — an external index of extension names to targets and methods, consulted to decide dispatch. The engine would be trusting a generated, stale-able artifact for language-semantics decisions. External metadata is acceptable only as a load-ordering hint with a full fallback pass (Future Scope), never as the thing that decides whether a method exists.

Changelog

  • 1.1 (2026-07-11): added “Why the import set is the resolution key” (no method→extension map exists before load; the finite import set is what distinguishes this from function autoloading); rejected import-site target claims (use extension ... for Target) and method-indexed metadata as a resolution key; specified the target-hint loading optimization in Future Scope with its three design constraints, including the conflict-resolution prerequisite.
  • 1.0 (2026-07-11): initial draft, written alongside the phase-3 implementation.
rfc/extension_methods_autoload.txt · Last modified: by thecodelorax