Overriding Composer packages at the source level

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.

A big head of a statue sitting on a pedestal.
Photo by Arralyn from Pexels

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 the dump-autoload command.

https://getcomposer.org/doc/articles/scripts.md#event-names

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

A headless mannequin wearing a dress.
Photo by Shuxuan Cao from Pexels

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.