Skip to content

Instantly share code, notes, and snippets.

@shanept
Created March 26, 2024 14:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shanept/076c4e2b9175de166bab69d5980cd0e7 to your computer and use it in GitHub Desktop.
Save shanept/076c4e2b9175de166bab69d5980cd0e7 to your computer and use it in GitHub Desktop.
PSR-4 compatible Autoloader

Autoloader

This contains a simple, zero dependency autoloader class for use in PHP 7.0+ projects. It is very simple to configure and use, and fully supports PHP's case-insensitive namespacing.

The autoloader implements two strategies to find classes. These are namespace mapping, and filesystem lookups. Namespace mapping consists of configuring a mapping for a namespace to a directory in the filesystem. The autoloder will then find the best match for a class in the list of mappings and check the filesystem to see if the class exists at that location. If the namespace mapping failes, then the autoloader will search the filesystem for the namespaced class from the configured root directory.

For example, we have a class example\Loader\MyClass to be loaded. In the namespace map, we have configured that the example namespace should be mapped to src/. Thus, the autoloader namespace mapper will look for src/Loader/MyClass.php. However, if that does not exist, the namespace mapping will fail, we will fall back to the filesystem loader and will look for example/Loader/MyClass.php.

Now let's say that we are looking for example\Loader\MyClass and have configured the example namespace to be loaded from the src/ directory. We have also configured example\Loader to be loaded from loader/ directory (outside the src/ dirctory). The namespace mapper will no longer attempt to load from src/Loader/MyClass.php, and will only attempt to load from loader/MyClass.php. This is because the namespace mapper only attempts to load the best match.

How to use

The first step is to instantiate the autoloader. The constructor takes the following 3 parameters:

  • Filesystem Root
  • Namespace Prefix (optional)
  • Namespace Map (optional)

Filesystem Root

This specifies the root directory under which the autoloader will search for files.

Namespace Prefix

The Namespace Prefix specifies the namespace under which all autoloaded classes are included. If the autoloader is configured

Namespace Map

The Namespace Map is a 2D array of namespaces mapped to one or more directories. An initial 2D array may be passed to the constructor, and additional namespaces may be loaded after. The namespace map array must follow this format:

[
    '\\' => [
        'rootNamespace/',
        'alternateDirectory/',
    ],
    '\\namespace1' => [
        'directory1a/',
        'directory1b/',
        'directory1c/',
    ],
    '\\namespace1\\subNS' => [
        'directory2a/subDirectory/',
    ],
]

NOTE the leading backslashes, and the trailing directory slashes. This is the required format for the autoloader.

Using the Autoloader

To use the autoloader, simply instantiate it with the necessary parameters (as above) and call register(), then you are ready!

<?php
use shanept\Autoloader;

$directory = __DIR__;
$prefix = 'shanept\MyProject';
$mapping = [
    '\\' => 'src/'
];

$autoloader = new Autoloader($directory, $prefix, $mapping);
$autoloader->register(); // Now we are good to go!

// This class will be autoloaded from src/MyClass.php
$class = new shanept\MyProject\MyClass();

// You can register more namespaces
$newDirectories = [
    'src/sub1',
    'src/sub2',
    'src/sub3',
];

$autoloader->registerNamespace('\\NewNS', $newDirectories);

// This is loaded from src/sub(1 or 2 or 3)/AnotherClass.php
$newClass = new shanept\MyProject\NewNS\AnotherClass();

Autoloader::registerNamespace()

The autoloader additionally supports adding new namespaces after instantiation. This may be achieved with a call to registerNamespace(). The function takes 2 parameters, as follows:

  • The namespace
  • The list of directories

The format of these parameters is as above for the Namespace Map, where the first parameter becomes the array key, and the list of directories becomes the second dimension/value.

For example:

<?php
$autoloader->registerNamespace('\\myNS', [
    'dir1/',
    'dir2/',
]);

// This generates the following namespace map:
[
    '\\myNS' => [
        'dir1/',
        'dir2/',
    ]
]
<?php
namespace shanept;
/**
* A simple, case-insensitive autoloader for use in PHP projects.
*/
class AutoLoader
{
private $fs_basepath;
private $ns_prefix;
private $ns_map;
/**
* @param string $basepath The base path for the autoloader
*/
public function __construct($basepath, $prefix = null, array $map = [])
{
$basepath = rtrim($basepath, DIRECTORY_SEPARATOR);
$this->fs_basepath = $basepath;
// If we do not have a prefix, default to an empty string.
$this->ns_prefix = rtrim($prefix ?? '', '\\');
$this->ns_map = $map;
}
/**
* Allows us to register namespace mappings after instantiation.
*
* Simply appends to any pre-existing namespace mappings if they already
* exist.
*
* @param string $namespace to be registered
* @param array $ns_maps an array of directories to search
*
* @return void
*/
public function registerNamespace(string $namespace, array $ns_maps)
{
if (! array_key_exists($namespace, $this->ns_map)) {
$this->ns_map[$namespace] = $ns_maps;
return;
}
$this->ns_map[$namespace] = array_merge(
$this->ns_map[$namespace],
$ns_maps
);
}
/**
* Registers the autoloader
*
* @internal
*/
public function register()
{
spl_autoload_register([ &$this, 'load' ]);
}
/**
* The registered callback
*
* @internal
*/
public function load($class)
{
// Filter out anything that doesn't start with our prefix
$prefix_len = strlen($this->ns_prefix);
if (strncmp($this->ns_prefix, $class, $prefix_len) !== 0) {
return;
}
// Remove the namespace prefix from the class mapping
if ($class === $this->ns_prefix) {
$class = '\\';
} else {
$class = substr($class, $prefix_len);
}
// First try loading from a map, then try the filesystem.
$this->loadFromMap($class) or $this->loadFromFilesystem($class);
}
/**
* Helper to find and load a class from the self::NS_MAP
*/
protected function loadFromMap($class)
{
// Remove the prefix, split the namespace from the class name
$lastPos = strrpos($class, '\\');
$namespace = substr($class, 0, $lastPos);
$class = substr($class, $lastPos + 1);
// Get the best match for a namespace
$mapped_namespace = $this->getClosestNamespaceFromMap($namespace);
// We may only have a mapping of a partial namespace. Let's adjust
$ns_partial = str_ireplace($mapped_namespace, '', $namespace);
// Check mapped namespace entries for the file
foreach ($this->ns_maps[ $mapped_namespace ] as $entry) {
$file = sprintf(
'%s/%s%s/%s.php',
$this->fs_basepath,
$entry,
str_replace('\\', DIRECTORY_SEPARATOR, $ns_partial),
$class
);
if (! file_exists($file)) {
continue;
}
require $file;
return true;
}
return false;
}
/**
* Helper to find a class from the filesystem and load it.
*/
protected function loadFromFilesystem($class)
{
$file = sprintf(
'%s/%s.php',
$this->fs_basepath,
substr(str_replace('\\', DIRECTORY_SEPARATOR, $class), 1)
);
if (file_exists($file)) {
require $file;
return true;
}
return false;
}
/**
* Helper function for ::LoadFromMap that finds the most suitable
* match for a class in the NS_MAP and returns it.
*/
protected function getClosestNamespaceFromMap($namespace)
{
static $maps = null;
$mapped_namespace = [];
if (is_null($maps)) {
$maps = array_keys($this->ns_maps);
}
for ($i = 0; $i < count($maps); $i++) {
// We have a direct match. Take it and continue
if (
'\\' == $maps[$i] ||
strtolower($maps[$i]) === strtolower($namespace)
) {
$mapped_namespace[] = $maps[$i];
continue;
}
/**
* We will perform an XOR between the namespace we are searching
* for and the current map under test. This will produce a
* string where any matching characters become a \0 character.
* Note: PHP namespaces are case-insensitive. We will compare
* in consideration.
*/
$mask = strtolower($namespace) ^ strtolower($maps[$i]);
$mask_len = min(strlen($namespace), strlen($maps[$i]));
$match_len = $mask_len - strlen(ltrim($mask, "\0"));
/**
* The namespace in which we are testing must completely mask
* the map for us to determine it is in fact a sub-namespace
* of this map. This means the match should be the same
* length as the map we are testing.
*
* If we pass this check, we are guaranteed that the namespace
* will be longer than the current map under test.
*/
if ($match_len < strlen($maps[$i])) {
continue;
}
/**
* We are not done yet. We must ensure that the last matched
* character was a namespace separator. Otherwise we would
* get matches for \name\space when we are in fact looking
* for \name\space2.
*/
if ('\\' === $namespace[$match_len]) {
// We have a match
$mapped_namespace[] = $maps[$i];
}
}
// Sort the mapped namespaces from best to worst match
rsort($mapped_namespace);
// Return only the best match
return $mapped_namespace[0] ?? [];
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment