Module loader script for composer
Writing a large maintainable application requires a well organized code base. A term often used for this is domain driven design. One of the core ideas behind domain driven design is to split the code in logical chunks that live in their own directories. A caveat of this is that when you have many domains you would have to either have a parent namespace or give each domain their own namespace.
Both solutions have problems
The first method does not let you organise additional non-namespaced files and/or directories at the root of the domain. It's a simpler approach because you only need to add one namespace to the autoload configuration.
1{2 "autoload": {3 "psr-4": {4 "Modules\\": "modules"5 }6 }7}
The problem with this method is that all folders in a module is linked to the namespace. I prefer using a 'src' directory to house the domain code, and a 'tests' directory for the tests. So in this case the namespace could end up being something like this:
Modules\Foo\src\Models\Bar
. This works, but it bungles up the naming convention of the namespace where the first character in each part is an upper-case letter.
We can do better.
The second method requires you to add each domain to the composer.json, but this quickly become tedious to maintain if you have a lot of domains in the project.
Here's an example of what that looks like using composer.json
1{ 2 "autoload": { 3 "psr-4": { 4 "Foo\\": "modules/Foo/src", 5 "Bar\\": "modules/Bar/src" 6 } 7 }, 8 "autoload-dev": { 9 "psr-4": {10 "Foo\\Tests\\": "modules/Foo/tests",11 "Bar\\Tests\\": "modules/Bar/tests"12 }13 }14}
The solution
I've been tinkering with some composer plugins and scripts in the past and that made me wonder if it's possible to change the autoloader on the fly, or at least when the autoloader is generated/dumped.
Composer has a hook right before dumping that we can latch on to, pre-autoload-dump
.
File: composer.json
1{2 "scripts": {3 "pre-autoload-dump": [4 "App\\Support\\ModuleLoader::load"5 ],6 }7}
With that I wrote a quick a support class, ModuleLoader
, with the code to change the autoloader.
Initially I thought it would be nice to let the script do all the work and let the composer.json remain unchanged.
After testing this a little I discovered a flaw in this aproach, and that is that the IDE I'm using, phpstorm, does not recognize the namespace.
I didn't test with other tools, but I assume they would have the same problem. To solve this issue it became necesarry to write the changed autoloader back to the composer.json file.
File: app/Support/ModuleLoader.php
1<?php 2 3namespace App\Support; 4 5use Composer\Config\JsonConfigSource; 6use Composer\Script\Event; 7 8class ComposerModuleAutoLoader 9{10 public static function load(Event $event): void11 {12 $package = $event->getComposer()->getPackage();13 $base_path = dirname(__DIR__, 2).'/';14 $dirs = glob(15 $base_path.'modules/*',16 GLOB_ONLYDIR17 );18 $paths = array_map(19 fn($dir) => str_replace($base_path, '', $dir),20 $dirs21 );22 $autoload = $package->getAutoload();23 $autoload['psr-4'] = array_merge(24 $autoload['psr-4'],25 self::generatePsr4($paths, false)26 );27 $package->setAutoload($autoload);28 29 $autoloadDev = $package->getDevAutoload();30 $autoloadDev['psr-4'] = array_merge(31 $autoloadDev['psr-4'],32 self::generatePsr4($paths, true)33 );34 $package->setDevAutoload($autoloadDev);35 36 $configSource = $event->getComposer()->getConfig()->getConfigSource();37 self::updateComposerJson($configSource, 'autoload', $autoload);38 self::updateComposerJson($configSource, 'autoload-dev', $autoloadDev);39 self::reformatComposerJson($configSource->getName());40 }41 42 private static function generatePsr4(array $paths, bool $dev): array43 {44 $psr4 = [];45 foreach ($paths as $path) {46 $namespaces = self::getNamespacesFromPath($path, $dev);47 $psr4 = array_merge(48 $psr4,49 $namespaces50 );51 }52 return $psr4;53 }54 55 private static function getNamespacesFromPath(string $path, bool $dev): array56 {57 // Get the relative path and the namespace name for each module directory.58 $name = str_replace("modules/", '', $path);59 if (!preg_match('/^[A-Z][\w]*$/', $name)) {60 return [];61 }62 $namespaces = [];63 if (!$dev && file_exists("$path/src") && is_dir("$path/src")) {64 $namespaces["Modules\\$name\\"] = "$path/src";65 }66 if ($dev && file_exists("$path/tests") && is_dir("$path/tests")) {67 $namespaces["Modules\\$name\\Tests\\"] = "$path/tests";68 }69 return $namespaces;70 }71 72 private static function updateComposerJson(JsonConfigSource $configSource, string $key, array $autoload): void73 {74 // Add a placeholder so that the key "autoload" is not removed when removing psr-4.75 $configSource->addLink($key, 'tmp', 'placeholder');76 $configSource->removeLink($key, 'psr-4');77 // Add back psr-4 after changes and remove the placeholder key.78 $configSource->addLink($key, 'psr-4', $autoload['psr-4']);79 $configSource->removeLink($key, 'tmp');80 }81 82 private static function reformatComposerJson(string $filename): void83 {84 // Read the composer.json and re-format it to fix indentation.85 $content = file_get_contents($filename);86 $json = json_encode(87 json_decode($content, true),88 JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES89 );90 if (!$json) {91 return;92 }93 file_put_contents(94 $filename,95 $json96 );97 }98}
The ModuleLoader
class has a simple static method ::load
that lists all directories inside the modules
directory.
It then does some quick sanitazion and then adds the correct paths to the autoload array in composer.
A quick breakdown of what the script does:
- Scan the ./modules directory for all directories.
- Iterate through these and get an array of any valid namespaces
- Set the autoloader with the new changes
- Write the changes to composer.json
- Re-format the composer.json file to fix indentation
Conclusion
Is it worth adding a script to maintain the autoload section for each new domain? It depends. If you have only a few domains then it's less code and less work to just add them by hand. If you have a larger project where there are many domains to keep track of then this takes away some of the busy-work and is especially helpful for new developers when they start working on the project as they don't have to worry about registering new domains in the composer.json file.
Written by Eivin Landa • January 28, 2022 • Updated March 24, 2022