rfc:function-autoloading-five-oh

PHP RFC: Function Autoloading

Introduction

Class autoloading has existed in PHP for 20+ years. However, there is no equivalent for function autoloading. This RFC proposes function autoloading, modeled directly on class autoloading, with no cost to calls that resolve normally.

As with class autoloading, the consumer may register a callback that defines a function on demand (usually via include of a definition file) and the engine invokes it whenever a function name fails to resolve.

The following example autoloads functions on a one-function-per-file basis ...

// Register a loader that maps a function name to a file.
spl_autoload_register_function_loader(function (string $function) : void {
    $path = __DIR__ . '/functions/' . str_replace('\\', '/', $function) . '.php';
    if (is_file($path)) {
        require $path;
    }
});
 
// functions/Foo/greet.php defines Foo\greet(); it is loaded on first use.
echo \Foo\greet('world');

... but note that multiple functions might be defined in a single file as well. For an example of such logic see the Moto autoloading algorithm, which applies to classes as well as functions.

Proposal

Function autoloading is implemented essentially as a copy of class autoloading. Where the engine would have thrown Error: Call to undefined function, the implementation now consults any registered function loaders, one of which may define the missing function.

The implementation introduces four new global SPL functions to manage the function loaders, and an optional $autoload parameter is added to function_exists():

// new functions:
function spl_autoload_register_function_loader(
    callable $callback,
    bool $prepend = false
) : bool {}
 
function spl_autoload_unregister_function_loader(
    callable $callback
) : bool {}
 
function spl_autoload_function_loaders() : array {}
 
function spl_autoload_call_function_loader(
    string $function_name
) : void {}
 
// existing function, new optional second parameter:
function function_exists(
    string $function,
    bool $autoload = true
) : bool {}

The loader callback

A function loader is any callable taking one string argument: the function name as resolved at the call site, fully qualified, leading backslash removed, original case preserved.

That resolved name may be namespaced (bar() inside namespace Foo becomes Foo\bar).

It may also be global (a top-level bar(), an explicit \bar(), or a string passed to an API such as call_user_func() or function_exists()).

The loader callback is expected to define the function if it can (typically by requiring a file), or do nothing if it cannot. Its return value is ignored.

When loaders run

Loaders run only when a name fails to map to a defined function, on the same failed-lookup path that today throws Error: Call to undefined function.

A call to an already-defined function never invokes the loaders; such calls are “free” in performance terms.

Loading may occur at any site that resolves a function name by value:

  • direct calls (bar()) and dynamic calls through a variable ($fn());
  • function_exists(), unless autoloading is disabled (see below);
  • the Reflection API (new ReflectionFunction($name));
  • callable resolution via ...
    • is_callable();
    • call_user_func() and call_user_func_array();
    • array and sort callbacks such as array_map(), array_filter(), and usort();
    • callable-typed parameters; and,
    • Closure::fromCallable().

JIT-compiled code honors it as well.

A few contexts do not autoload, matching their equivalents on the class side:

  • function_exists($name, false), an explicit table check;
  • is_callable($name, true), the syntax-only check;
  • get_defined_functions(), an enumeration of what is already defined.

spl_autoload_call_function_loader(string $function_name) runs the chain manually for these cases. It triggers every registered loader for the name and never throws if none defines it.

Namespace resolution

For an unqualified call inside a namespace, PHP looks first in the current namespace, then falls back to the global function table.

Function autoloading adds a followup step: if both miss, the loader chain is consulted once, using the fully-qualified name as resolved at the call site. The engine then rechecks only that name.

This has three consequences:

  • Results are not pinned. Falling back to a global function does not register the namespaced name, so function_exists('Foo\bar', false) stays false and Foo\bar remains autoloadable on a later fully-qualified call.
  • If a loader is asked for Foo\bar but defines the global bar instead, the current call still fails with Error: Call to undefined function Foo\bar() (an answer to a different question is not rebound); the next bar() call finds the global through the fallback, without re-consulting the loader.
  • If a global function of that name exists, the call resolves to that global function through the fallback, and the loader is never consulted.

I don't want to understate the case here: that last point might well be considered unexpected behavior based on how class autoloading works.

The Class Loading Case

Let's say we have the namespace Foo, and in some file a namespaced stdClass class, and autoloading is set to load that file based on the class name. It has not been loaded yet, but the global stdClass is already loaded because it is built in to PHP.

namespace Foo;
 
class stdClass {}

In another file, also in the Foo namespace, we instantiate an unqualified stdClass, and the autoloader gives us the Foo\stdClass class:

namespace Foo;
 
$stdClass = new stdClass(); // instanceof Foo\stdClass

Class name resolution never falls back to the global namespace when an unqualified name does not resolve to an already-loaded class in the same namespace.

The Function Loading Case

But it will not work that way with function autoloading.

Let's say we have the namespace Foo, and in some file a namespaced strlen() function, and autoloading is set to load that file based on the function name. It has not been loaded yet, but the global strlen() is already loaded because it is built in to PHP.

namespace Foo;
 
function strlen(string $str) : int
{
    // Foo\strlen() always returns -1
    return -1;
}

In another file, also in the Foo namespace, we call an unqualified strlen(), but the autoloader will not trigger; we get the global strlen() function instead:

namespace Foo;
 
$len = strlen('bar'); // 3

This is because function name resolution always falls back to the global namespace when an unqualified name does not resolve to an already-loaded function in the same namespace.

That is, an unqualified strlen() call from within the Foo namespace will always fall back to the global strlen() when Foo\strlen() is not already loaded.

The "Fix"

This is a narrow failure mode but very real and unexpected.

To make unqualified calls to strlen() resolve to Foo\strlen() inside the Foo namespace, you must import the fully qualified function first:

namespace Foo;
 
use function Foo\strlen;
 
$len = strlen('bar'); // -1

Now the resolution logic will look specifically for Foo\strlen(), miss, and trigger the autoloader.

Having to import a function from the same namespace is a chore but I don't see a way around it without modifying the name resolution rules.

Registry, ordering, and introspection

Function loaders live in their own registry, separate from class loaders: registering one has no effect on class autoloading, or vice versa.

spl_autoload_register_function_loader() appends a loader, or with $prepend = true prepends it. Loaders are consulted in order, and the first to define the function ends the pass. Registering the same callable twice is a no-op.

spl_autoload_function_loaders() returns the registered callables in order.

spl_autoload_unregister_function_loader() removes one, returning true if it was registered, false otherwise.

Passing spl_autoload_call_function_loader itself to the register function throws a ValueError.

Loaders may register or unregister loaders mid-pass; iteration is safe.

function_exists() and the $autoload parameter

function_exists() gains an optional second parameter $autoload, defaulting to true like class_exists(). When true, a missing function triggers the chain before the result is reported; when false, the table is checked without autoloading.

The true default is a behavioral change (see Backward Incompatible Changes).

Errors, exceptions, and recursion

Loader exceptions propagate. At direct and dynamic call sites and from is_callable() they surface directly. The call APIs (call_user_func(), Closure::fromCallable()) wrap them in a TypeError with the loader's exception as the previous exception. They are never suppressed.

Failed autoloads are not cached, positively or negatively. A loader that declines silently (throws nothing, defines nothing), or one that throws, does not poison the name: the next reference consults the chain again, so a name unloadable now may load later.

Re-entrant autoloading of the name being resolved is blocked: if a loader resolving bar calls bar(), that nested call fails with Call to undefined function rather than recursing.

Examples

Basic one-function-per-file loader:

spl_autoload_register_function_loader(function (string $function) : void {
    // map "Foo\Math\add" to functions/Foo/Math/add.php
    $path = __DIR__ . '/functions/' . str_replace('\\', '/', $function) . '.php';
    if (is_file($path)) {
        require $path;
    }
});
 
// first reference loads functions/Foo/Math/add.php on demand
echo \Foo\Math\add(2, 3); // 5

See the Moto autoloader for a loader that handles more-than-one function per file.

Namespace edge case: a global fallback wins without consulting the loader, but a fully-qualified call autoloads:

namespace Foo;
 
// define a global helper()
\eval('function helper() { return "global"; }');
 
\spl_autoload_register_function_loader(function (string $name) : void {
    echo "loader($name)\n";
    if ($name === 'Foo\helper') {
        eval('namespace Foo; function helper() { return "namespaced"; }');
    }
});
 
// a global helper() exists, so an unqualified call resolves to it through the
// global fallback and the loader is NOT consulted
\var_dump(helper());           // string(6) "global"
 
// a fully-qualified call has no global fallback, so it autoloads Foo\helper
\var_dump(namespace\helper());  // loader(Foo\helper), then string(10) "namespaced"

Polyfill guard, now that function_exists() autoloads by default:

spl_autoload_register_function_loader(function (string $name) : void {
    if ($name === 'acme_slug') {
        eval('function acme_slug(string $s) : string { return strtolower(strtr($s, " ", "-")); }');
    }
});
 
// default: function_exists() autoloads, so the loader defines acme_slug()
// and the polyfill body below is skipped
if (! function_exists('acme_slug')) {
    // ... define a fallback ...
}
 
\var_dump(function_exists('acme_slug', false)); // bool(true): the loader defined it
 
// opt out: check the table only, do not autoload.
\var_dump(function_exists('made_up_func', false)); // bool(false): loader not consulted

Backward Incompatible Changes

Two changes are observable to existing code.

1. function_exists() autoloads by default. With $autoload defaulting to true (like class_exists()), a check on a missing function now consults registered loaders before returning. Consider the common polyfill idiom:

if (! function_exists('bar')) {
    function bar() { /* fallback */ }
}

If a loader for bar is registered, this calls it before defining the fallback. For a pure table check, pass function_exists($name, false). With no loaders registered (the default), behavior is unchanged.

2. Four new global function names. spl_autoload_register_function_loader, spl_autoload_unregister_function_loader, spl_autoload_function_loaders, and spl_autoload_call_function_loader enter the global namespace. Userland functions with these exact names would conflict, but the names are specific enough to make that rare.

There are no other changes; existing class autoloaders cannot tell this feature exists.

Proposed PHP Version(s)

Next PHP 8.x (targeting PHP 8.6).

RFC Impact

To the Ecosystem

Static analyzers, IDEs, and language servers will want to account for an undefined function being autoloaded, as they already do for classes. The feature makes per-function autoloading viable for function libraries, without forcing functions into static methods or eagerly-required files.

To Existing Extensions

The implementation lives in core (Zend/zend_autoload.c), along with the class autoloading hook: a zend_function_autoload hook next to zend_autoload, and zend_lookup_function() paralleling the class lookup.

One new internal API, zend_autoload_function_fcc_map_to_callable_zval_map(), is the function-loader counterpart to the existing zend_autoload_fcc_map_to_callable_zval_map(), which keeps its name and behavior (it still backs spl_autoload_functions()). Dependent extensions are unaffected, and opcache/JIT is updated to honor it.

To SAPIs

No SAPI-specific impact.

Open Issues

None known.

Future Scope

Should the userland API move from the spl_autoload_* prefix to a core autoload_* naming? (This RFC keeps the spl_autoload_ prefix and takes no position on a future rename.)

Out of scope, and proposable separately:

  • autoloading of constants and stream wrappers;
  • autoloading triggered by include/require;
  • function aliasing and a default convenience loader for functions (an spl_autoload-style helper);
  • any changes to function name resolution order.

Voting Choices

Primary Vote requiring a 2/3 majority to accept the RFC:

Implement Function Autoloading as described in this RFC?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Patches and Tests

Implementation: https://github.com/php/php-src/pull/22337

Tests accompany the implementation:

  • Zend/tests/autoload/function_autoload_*.phpt: registration, invocation across all trigger sites, loader chains and ordering, exception propagation and the absence of caching, namespace resolution (including the four-scenarios and no-pinning cases), name normalization, callable kinds, recursion and mutation during a pass, manual triggering, registry independence from class loaders, and use function imports;
  • ext/opcache/tests/jit/function_autoload_00{1,2,3}.phpt: JIT-compiled code honors autoloading;
  • ext/spl/tests/autoloading/spl_autoload_register_function_loader_rejects_call_function_loader.phpt: the ValueError guard.

Implementation

After the RFC is implemented, this section should contain:

  1. the version(s) it was merged into
  2. a link to the git commit(s)
  3. a link to the PHP manual entry for the feature

References

Rejected Features

Two approaches that recurred across earlier proposals are explicitly rejected here.

  • A single registry with a type flag. Several proposals (2011, 2013, 2024) routed class and function loading through one registration function plus a $type argument (or an AUTOLOAD_* bitmask), with the callback switching on type. This RFC uses a separate registry and a single-argument callback, so no callback switches on a type, and adding function support cannot change the arguments existing class autoloaders receive.
  • Pinning lookup results. The 2023 core-autoloading RFC pinned an unqualified namespaced lookup to the global function it fell back to, to cap probing cost; it noted this leaked into function_exists() as a BC break. This RFC runs the loader only after the global fallback fails, so there is nothing to pin: defined functions are never probed, and function_exists() reports only functions that exist.

A condensed comparison of the principal prior proposals:

Proposal Year Status Registration Callback receives BC surface
rfc/autofunc (Kovacs) 2011 Inactive spl_autoload_register($cb, ..., $types) ($name, $type) new args to existing API and callbacks
function_autoloading2 (Ferrara/Wiedler) 2013-15 Draft php\autoload_register($cb, $type) ($name, $type) SPL proxied; SPL globals removed
core-autoloading (Banyard/Ackroyd) 2023 Under Discussion autoload_register_function() (new family) ($name) API renamed/aliased; pinning visible in function_exists()
function_autoloading4 (Landers) 2024 Discarded spl_autoload_register($cb, ..., $types) ($name, $type) broke existing callbacks (Symfony)
This RFC 2026 Draft spl_autoload_register_function_loader() (new, separate) ($name), FQN no existing signatures changed; function_exists() default now true

Changelog

  • 2026-06-16: Initial draft (0.1.x).
rfc/function-autoloading-five-oh.txt · Last modified: by pmjones