Table of Contents

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:

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):

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

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

Changelog