Why do we Scope PHP Tools

Do you know PHPStan, ECS, Monorepo Builder, PHPUnit, Config Transformer or Rector?

What do they have in common? They're PHP tools you install with composer and then run in your command line. Hm, what else? They're all scoped with help of php-scoper.

Do you want to make your tool resistant to any conflict with any project dependencies? Today I'll show you how.

Let's say you want to install symplify/monorepo-builder to any of your projects with composer:

composer require symplify/monorepo-builder --dev

What can happen?

  • [ERROR] conflicts with your PHP version
  • [ERROR] conflicts with your symfony/console, must be 5.2+, you have 4.4
  • [ERROR] conflicts with your another-dependency, must be X, you have Y

Now you have to go to your composer.json, figure out what conflicts you can solve. You can also try to go to the project repository and ask for lowering the minimal required version of this or that package. This is typical for unscoped packages and natural results for semver strategy of PHP packages ecosystem.

But do we care about the dependencies of the tool we use? No, our goal is to run the tool:

composer require symplify/monorepo-builder --dev
vendor/bin/monorepo-builder <command>

How Can We Get rid of Tool's Dependencies?

Before scoping
After scoping

We'll scope the tool! Only then we're conflict-free! Our project can have symfony/console 3.4 or 5.4-dev. Nobody cares because the only requirement is of the tool is the PHP version.

How is that possible?

Wait, how can we have 2 versions of symfony/console? Let's look at the file structure we'll find in /vendor if we install the following packages together:

composer require symfony/console:^3.4
composer require symplify/monorepo-builder # new scoped one

1. Your Command class

These steps will produce 2 different symfony/console directories with 2 different Symfony\Component\Console\Command\Command classes.

        /console # version 3.4, autoloaded by your project

That contains typical Command class as you know it

namespace Symfony\Component\Console\Command;

class Command
    // ...

Pretty standard, right?

2. Scoped Command class

Here the scoping magic happens. The symplify/monorepo-builder package it's own Command in it's own scoped /vendor. Like this:

            /vendor # this vendor is scoped and loaded only by monorepo-builder

Now, this Command class is a bit different. It's scoped. What does that mean exactly? It has its unique namespace prefix that makes class name unique to other Command:

namespace Scope1234\Symfony\Component\Console\Command;

class Command
    // ...

So now in the whole project we now have 2 Command classes:

  • Symfony\Component\Console\Command
    • loaded by your project autoload
    • in a version defined in your projects composer.json
    • you can use this class in your project, e.g. to create your own commands

  • Scope1234\Symfony\Component\Console\Command
    • loaded by monorepo-builder
    • in a version required by monorepo-builder
    • you will never see this class in your code

The second class was scoped by php-scoper, that makes all classes unique and accessible exclusively in the tool. This process removes dependency from composer.json and thus avoid conflicts on install.

Scope All The Things?

Now, should we get crazy and scope everything that pops up a conflict on composer require <x>? No. This process is valid only for PHP tools in the command line. We should not scope classic dependencies that we use directly in our project, e.g. nette/utils.

Where is Scoping Useful?

  • if we create an open-source PHP tool for the community
  • if our community uses composer
  • if we want to ease installation on both super modern and well-grown legacy projects

Now that we know why and what we scope, we'll look at how to do the scoping in the next post.

Happy coding!

Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!