I think we all have been in that situation where we had to extend some library class but there was something preventing us from doing it the normal way. A common example of this would be overriding private methods, or accessing private variables, or dealing with final classes. Another maybe less obvious case is when the class you want to extend is a hardwired dependency for code that you have no control over. In that that case, even if everything were public and overridable you would not be able to inject your subclass.
The scenario
In a previous blog post I wrote about wanting to override the function cleanBindings
on the Builder
class (source) from the Laravel framework. Extending the class itself is no problem because the function is public and the class is not marked final. The real problem here is that the Builder
is a hard-coded dependency multiple levels deep in the framework code.
Now, as a disclaimer, there are other ways to accomplish this task that are a lot more conventional than what I am about to show you. And I generally do not recommend that you use my solution for production code. With that out of the way, let’s look at the mechanism.
Overwriting instead of overriding
PHP being a scripting language, any dependency you install with Composer ends up being nothing more than a bunch of PHP source files on your disk. In theory, instead of overriding a library function by extending a class one could just open the corresponding source file under the vendor directory and edit the code directly.
Of course, this comes with a bunch of problems. For one, the change will only persist until you update or re-install the package. And second, nobody else on your team will receive any of your changes. Furthermore, you have to make sure that your change will not break anyone who depends on that function. This may also includes other packages you installed. But if you can guarantee the latter then it’s just a matter of automating the source code change.
Doing the dirty deed
We have to make sure that our source code changes get applied when installing the package and are not overwritten by updates. Conveniently, composer provides a hook for us in the form of the post-autoload-dump
event. The documentation states:
post-autoload-dump: occurs after the autoloader has been dumped, either during
install
/update
, or via thedump-autoload
command.
According to the composer scripts documentation we can define the hook callback as a static function in an autoloadable class. With our project structure as follows we can define our hook in the composer.json file.
project
|-- scripts
| +-- ComposerScripts.php
|-- src
|-- vendor
|-- composer.json
+-- composer.lock
{
"autoload": {
"psr-4": {
"App\\": "src/",
"Scripts\\": "scripts/"
}
},
"scripts": {
"post-autoload-dump": [
"Scripts\\ComposerScripts::postAutoloadDump"
]
}
}
Let’s quickly check if it works with this ComposerScripts
class.
<?php
namespace Scripts;
class ComposerScripts
{
public static function postAutoloadDump($event)
{
echo 'I am gonna use this'.PHP_EOL;
}
}
$ composer dump-autoload
Generating autoload files
Scripts\ComposerScripts::postAutoloadDump
I am gonna use this
Generated autoload files
Head transplant
Next we want to replace the function body of cleanBindings
in the source file with our own. But how do we do it in a way that is not so fragile as to break down the next time the Laravel devs update the file upstream?
We can make use of the fantastic nikic/PHP-Parser project to parse the PHP code into an AST, replace the function body, and then convert it back into PHP code. Sounds like a lot of work but the parser does all the heavy lifting for us.
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
function replaceCleanBindingsBody(string $srcPath)
{
$code = file_get_contents($srcPath);
// Parse the source code.
$parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
$stmts = $parser->parse($code);
// Find the node of the cleanBindings function in the AST.
$nodeFinder = new NodeFinder();
$builderClass = $nodeFinder->findFirstInstanceOf($stmts, Node\Stmt\Class_::class);
$cleanBindingsFunction = $nodeFinder->findFirst($builderClass->stmts, function(Node $node) {
return $node instanceof Node\Stmt\ClassMethod
&& $node->name->toString() === 'cleanBindings';
});
$newCleanBindingsCode = <<<'PHP'
<?php
function donor() {
// Up to you
return [];
}
PHP
;
// Transplant the body of the donor to our patient.
$donorFunction = $parser->parse($newCleanBindingsCode)[0];
$cleanBindingsFunction->stmts = $donorFunction->stmts;
// Dump PHP code.
$prettyPrinter = new \PhpParser\PrettyPrinter\Standard();
$newCode = $prettyPrinter->prettyPrintFile($stmts);
file_put_contents($srcPath, $newCode);
}
What’s left?
In terms of coding, we need to require the composer autoloader file in the ComposerScripts
class in order to use the PHP-Parser. I uploaded a full sample project with the things discussed in this post.
Use this power responsibly.
TO THE MAXIMUM EXTEND PERMITTED BY APPLICABLE LAW, THE AUTHOR OF THIS BLOG POST SHALL NOT BE LIABLE FOR ANY DAMAGE TO CODE BASES, OR PRODUCTION SYSTEMS, OR THE TIME LOST DUE TO HOUR-LONG DEBUGGING SESSIONS RESULTING FROM APPLYING THE TECHNIQUES DESCRIBED IN THIS BLOG POST. THE MATERIALS IN THIS BLOG POST COMPRISE THE AUTHOR'S VIEWS AND DO NOT CONSTITUTE PROFESSIONAL ADVICE, you know.
The post Overriding Composer packages at the source level appeared first on hbgl.
Top comments (0)