Overriding vendor classes

Published 27/12/2021 | 14220 views

Ever found yourself wanting to make a small tweak to a PHP file in a Composer dependency? Here's how to do it without forking the entire package.

Composer offers powerful tools for configuring class loading.

If you take a look at my blog, you'll see that I make frequent and heavy use of code blocks. Being a developer, this is an extremely important part of my content. It has to be right. On my old blog, I used to use CSS to give code blocks a nice background and mono font, but it just wasn't engaging enough.

For my 2021 blog update, I decided to use Torchlight to add some visual interest and a clearer reading experience to my posts. If you haven't heard of Torchlight, go check it out; it's an outstanding product by Aaron Francis and team.

When I went to install Torchlight, I encountered a potential showstopper. I'm using Wink for writing my posts, which ships with its own WinkPost Eloquent model. That model has a dynamic attribute, content, which looks like this.

/**
* Get the renderable post content.
*
* @return HtmlString
*/
public function getContentAttribute()
{
if (! $this->markdown) {
return $this->body;
}
 
$converter = new GithubFlavoredMarkdownConverter([
'allow_unsafe_links' => false,
]);
 
return new HtmlString($converter->convertToHtml($this->body));
}

Now, if you're not aware, GithubFlavoredMarkdownConverter is part of the League Commonmark package, and performs the work of converting our markdown to HTML. For Torchlight to be able to add syntax highlighting to our markdown code blocks, we need to add an extension to the convertor. The issue is, we don't have access to this code. The WinkPost lives inside the vendor folder and isn't made publicly available. Gulp.

Potential solutions

So, what's to be done? We could of course fork the entirety of the Wink codebase. Doesn't that seem overkill for one line of code though? It does to me.

We could instead access the content via another class, like a ContentManager. You would pass it a $post->body, and it would create its own GithubFlavoredMarkdownConverter, add the TorchlightExtension, and return the results. However, this would cost us the elegance of simply calling $post->content and automatically being provided with renderable HTML. You can also imagine that on a larger project, this could easily be missed and implemented differently in two separate locations.

Another option, which is no doubt the best solution in the long run, is to create a PR with your fix to the original repository. I will cover writing PRs for 3rd party libraries in a future post. By creating a PR, everybody can benefit from your solution, including your future self. However, this can take time because OSS maintainers already have a lot on their plate. They need to find time in their busy schedules to review, comment, test, discuss and ship your PR.

Is there a solution that's fast, requires little maintenance overhead, and can easily be reverted in the future, say once your PR is merged? Why yes. Yes there is.

Overriding vendor files with Composer

In case you didn't know, all of your project dependencies are managed using a service called Composer. In all honesty, if you didn't know that, you should probably come back to this post once you're a little more familiar with the ecosystem, because what I'm about to show you is a very sharp knife that, used incorrectly, can cause more harm than good.

Composer is configured by a json file at the root of your project, aptly named composer.json. Let's take a look at that file.

{
"name": "lukeraymonddowning/blog",
"type": "project",
"description": "My personal blog.",
"keywords": ["framework", "laravel"],
"license": "MIT",
"require": {
"php": "^8.0",
"abraham/twitteroauth": "^3.2",
"andreiio/blade-remix-icon": "^1.0",
"fruitcake/laravel-cors": "^2.0",
"guzzlehttp/guzzle": "^7.0.1",
"laravel/framework": "^8.65",
"laravel/sanctum": "^2.11",
"laravel/tinker": "^2.5",
"nedwors/navigator": "^0.2.0",
"spatie/laravel-feed": "^4.0",
"spatie/laravel-health": "^1.5",
"thecodingmachine/safe": "^1.3",
"themsaid/wink": "^1.2",
"torchlight/torchlight-commonmark": "^0.5.2"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
// ...

I've focused in on one particular section of the composer.json file: the autoload node. When I first started writing PHP, autoloading was something of a mystery to me. When I needed a dependency, I would just include it at the top of the PHP script. Fun times.

<?php
 
include_once __DIR__ . '/MyClass.php';

Autoloading removes all of that manual pain by using namespaces to automatically load in the correct files. Laravel sort of hides this from us, but if you take a look at public/index.php, you'll see that it includes the autoloader for us.

<?php
 
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
 
define('LARAVEL_START', microtime(true));
 
/*
|--------------------------------------------------------------------------
| Check If The Application Is Under Maintenance
|--------------------------------------------------------------------------
|
| If the application is in maintenance / demo mode via the "down" command
| we will load this file so that any pre-rendered content can be shown
| instead of starting the framework, which could cause an exception.
|
*/
 
if (file_exists(__DIR__.'/../storage/framework/maintenance.php')) {
require __DIR__.'/../storage/framework/maintenance.php';
}
 
/*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader for
| this application. We just need to utilize it! We'll simply require it
| into the script here so we don't need to manually load our classes.
|
*/
 
require __DIR__.'/../vendor/autoload.php';
 
/*
|--------------------------------------------------------------------------
| Run The Application
|--------------------------------------------------------------------------
|
| Once we have the application, we can handle the incoming request using
| the application's HTTP kernel. Then, we will send the response back
| to this client's browser, allowing them to enjoy our application.
|
*/
 
$app = require_once __DIR__.'/../bootstrap/app.php';
 
$kernel = $app->make(Kernel::class);
 
$response = tap($kernel->handle(
$request = Request::capture()
))->send();
 
$kernel->terminate($request, $response);

Okay, what's this got to do with anything? Well, Composer actually allows us to "hack in" to this autoloading process. We can tell Composer to avoid automatically loading a file at a specific namespace. In this case, the namespace to avoid will be Wink\WinkPost. All we have to do is add a new exclude-from-classmap entry to the autoload node in our composer.json file.

"autoload": {
"exclude-from-classmap": [
"Wink\\WinkPost"
],
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},

At this point, your project is broken. If you've referenced Wink\WinkPost anywhere in your project, it will no longer be able to find it. So, we need to replace that file with our own version. Let's copy the full contents of vendor/themsaid/wink/src/WinkPost.php and paste it in a new file: .overrides/WinkPost.php. The actual folder name doesn't matter, I've just called it .overrides for clarity. You must ensure that the namespace is identical to the original.

Now, we'll add another entry to the autoload node: files.

"autoload": {
"exclude-from-classmap": [
"Wink\\WinkPost"
],
"files": [
".overrides/WinkPost.php"
],
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},

Notice that we include our custom WinkPost.php file in that array. Think of the files node as a dumb array of files that Composer will load regardless of namespacing standards. Because we are using the Wink\\WinkPost namespace in our file, any reference to it in our codebase will call our custom file instead. Our application will be none the wiser, but we now have full access to the WinkPost class. How cool is that?

One more thing. You probably need to dump the autoloader after these changes to make sure that Composer has registered your new file.

composer dump-autoload

Editing our new file

With that done, we can make any changes we'd like to .overrides/WinkPost.php, and they'll be reflected in our application. Let's add support for Torchlight.

public function getContentAttribute()
{
if (! $this->markdown) {
return $this->body;
}
 
$converter = new GithubFlavoredMarkdownConverter([
'allow_unsafe_links' => false,
]);
 
$converter->getEnvironment()->addExtension(new TorchlightExtension());
 
return new HtmlString($converter->convertToHtml($this->body));
}

Done! That wasn't so bad now was it?

Caveats and gotchas

No solution is without its cons. It's important to remember that if the original project updates the file you've overridden, you'll need to manually copy over those changes. Because of that, use this very sparingly. It may also be a good idea to make it very clear what you have actually altered in the file so that you can easily reference that when making updates.

public function getContentAttribute()
{
if (! $this->markdown) {
return $this->body;
}
 
$converter = new GithubFlavoredMarkdownConverter([
'allow_unsafe_links' => false,
]);
 
// OVERRIDE
$converter->getEnvironment()->addExtension(new TorchlightExtension());
// END OVERRIDE
 
return new HtmlString($converter->convertToHtml($this->body));
}

Conclusion

By making full use of Composer, we're able to override classes that would otherwise be uneditable to suit our needs. I've made use of this in my blog, in the pest parallel plugin, and in a number of other projects.

If you find yourself having to override more than a couple of files from a dependency, it's likely an indication that you should fork that project instead and maintain your own branch of it.

Still, the speed and simplicity of class overrides in cases like this one can really come in handy, and is an invaluable tool to have in your developer kit for when the need arises.

Thanks for reading!

Kind Regards, Luke

Like what you see?

If you enjoy reading my content, please consider sponsoring me. I don't spend it on cups of coffee; it all goes towards freeing up more of my time to work on open source, tutorials and more posts like this one.