PHP RFC: Function Autoloading (mark 5)
- Version: 0.2
- Date: 2026-07-07
- Author: Paul M. Jones, pmjones@pmjones.io
- Status: Under Discussion
- Implementation: https://github.com/php/php-src/pull/22337
- Discussion thread: https://externals.io/message/131332
- Voting thread: tbd
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.
This RFC also proposes a declare(strict_namespace=1) directive that turns off the global fallback for unqualified function and constant names in a file, so a namespaced call reaches the loader instead of resolving to a global of the same name. The new directive addresses a name-resolution gotcha covered under Namespace resolution problems.
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()andcall_user_func_array();- array and sort callbacks such as
array_map(),array_filter(), andusort(); 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 problems
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)staysfalseandFoo\barremains autoloadable on a later fully-qualified call. - If a loader is asked for
Foo\barbut defines the globalbarinstead, the current call still fails withError: Call to undefined function Foo\bar()(an answer to a different question is not rebound); the nextbar()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.
This is a narrow failure mode but very real and unexpected.
Fixes for namespace resolution
There are two ways to make an unqualified strlen() resolve to Foo\strlen() inside the Foo namespace.
Use function import
The first is to import the fully qualified function:
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, though, and it only covers the one name you import.
Strict namespace mode
The second is to turn off the global fallback for the whole file with declare(strict_namespace=1). Then every unqualified function (and constant) name resolves to the current namespace, so strlen() is Foo\strlen and autoloads on its own:
declare(strict_namespace=1); namespace Foo; $len = strlen('bar'); // Foo\strlen: autoloads, then -1
declare(strict_namespace=1) disables the global fallback for the file that declares it. With it in effect, an unqualified function or constant name inside a namespace resolves to the current namespace only: an unqualified strlen() in namespace Foo is Foo\strlen, misses, and consults the function loaders, the same path an unqualified class name already takes. Within that file, function autoloading behaves the way class autoloading does.
A name still reaches the global table when you ask for it directly. A fully qualified call (\strlen()) resolves to the global function without autoloading, and so does an imported name, whether use function strlen; or use function str_repeat as repeat;. The literals true, false, and null are keywords and resolve as always.
The declaration follows the same rules as strict_types:
- it must be the first statement in the file;
- it cannot use block mode;
- its value must be
0or1, and0(the current behavior) is the default.
It is resolved entirely at compile time and applies only to the file that declares it. Other files, callers, and callees are unaffected, and there is no runtime or JIT cost.
The same rule governs unqualified constants, which share the fallback behavior. Under strict_namespace=1 an unqualified GREETING in namespace Foo is Foo\GREETING, and even a built-in like PHP_INT_MAX reads as Foo\PHP_INT_MAX unless written \PHP_INT_MAX or imported with use const. Constant expressions behave the same way: a const value, a parameter default, an attribute argument. As with functions, true/false/null are untouched.
declare(strict_namespace=1); namespace Foo; use function str_repeat as repeat; \spl_autoload_register_function_loader(function (string $name) { if ($name === 'Foo\strlen') { eval('namespace Foo; function strlen($s) { return -1; }'); } }); // unqualified: resolves to Foo\strlen, which autoloads (no global fallback) \var_dump(strlen('hello')); // int(-1) // fully qualified reaches the global function directly \var_dump(\strlen('hello')); // int(5) // an imported name reaches the global function through the import \var_dump(repeat('ab', 3)); // string(6) "ababab"
How common is the shadow-global case?
Having said all that, the shadow-global case is not common. I researched the top 1000 packages on Packagist, tracking three things:
- unqualified calls to a builtin-named function inside a namespace;
- definitions of a namespaced function that shadows a builtin;
- and the hazard itself, an unqualified call that would bind to the global instead of loading its namespaced shadow.
While the unqualified call count is large (312,575 such calls across 75% of packages), the shadow function definition count is small: only 766 such definitions in 17 packages. Of those, 96% belong to two libraries, thecodingmachine/safe and azjezz/psl. And even then, those packages are written to be imported with use function and produce no hazards.
In the end, it looks like there are only 2 packages and 8 call sites that would behave unexpectedly if they converted to function autoloading and dropped their include calls. Either a use function import or declare(strict_namespace=1) would fix each one.
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 the default function name resolution order, beyond the opt-in
declare(strict_namespace=1)described above.
Voting Choices
Primary Vote requiring a 2/3 majority to accept the RFC:
Patches and Tests
Proof of concept: php/php-src pull request #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, anduse functionimports;Zend/tests/strict_namespace_*.phptandZend/tests/autoload/function_autoload_strict_namespace*.phpt: thedeclare(strict_namespace=1)directive: disabling the global fallback for functions and constants,useimports still reaching globals, and the first-statement, block-mode, and 0/1-value constraints;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: theValueErrorguard.
Implementation
After the RFC is implemented, this section should contain:
- the version(s) it was merged into
- a link to the git commit(s)
- a link to the PHP manual entry for the feature
References
- rfc/autofunc (Ferenc Kovacs, 2011)
- rfc/function_autoloading2 (Anthony Ferrara / Igor Wiedler, 2013)
- [DRAFT] [RFC] Function autoloading (externals, 2013)
- rfc/function_autoloading (Anthony Ferrara, 2015)
- rfc/core-autoloading (Gina P. Banyard / Dan Ackroyd, 2023)
- [RFC] New core autoloading mechanism (externals, 2023)
- rfc/function_autoloading4 (Robert Landers, 2024)
- function autoloading v4 RFC (externals, 2024)
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
$typeargument (or anAUTOLOAD_*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, andfunction_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-07-07: Add
declare(strict_namespace=1)directive, and research on the shadow-global case (0.2). - 2026-06-16: Initial draft (0.1.x).