Do you know PHPStan, ECS, Monorepo Builder, PHPUnit, Config Transformer or Rector?
In the previous post, we explored why are these tools scoped, where scoping makes sense and where not so much.
Do you maintain a PHP tool that runs in the command line? Today we'll look at 10 steps on how you can scope it too.
php-scoper is a tool that scans our project and its /vendor
. Then it adds a unique random prefix to every class:
-namespace Symfony\Component\Console\Command;
+namespace Scoper12345\Symfony\Component\Console\Command;
-use Symfony\Component\Console\Input\InputInterface;
+use Scoper12345\Symfony\Component\Console\Input\InputInterface;
class Command
{
protected function execute(InputInterface $inputInterface, ...)
{
}
// ...
}
We can install php-scoper as composer dependency. But soon, we'll get into a situation when php-scoper scopes itself and becomes part of the project. We don't want that.
It's safer to get a php-scoper PHAR file, the similar way we use composer as a PHAR file:
wget https://github.com/humbug/php-scoper/releases/download/0.14.0/php-scoper.phar -N --no-verbose
# then we have a file ready to run
php-scoper.phar ...
Php-scoper needs a configuration file, by convention, named scoper.php
. It's a file that returns an array.
The simpler this file is the better - e.g. this is how this config look like in PHPUnit:
# scoper.php
return [
'whitelist' => [
'PHPUnit\*',
],
];
How would such configuration look like for theSymplify\MonorepoBuilder
tool? First, we need to whitelist namespace of our tool.
Why? For 2 reasons:
PHPUnit\Framework\TestCase
and not from Scoper12HK32J2\PHPUnit\Framework\TestCase
# scoper.php
return [
'whitelist' => [
'Symplify\MonorepoBuilder\*',
],
'patchers' => [
// callback to process files after the scoping; we'll use them soon
]
];
Now, the php-scoper does a lot of work for us. Yet, it does not understand some framework-specific situations. E.g. Symfony autodiscovery in config/config.php
:
use Scoper12HK32J2\Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void
{
$services = $containerConfigurator->services();
$services->load('Scoper12HK32J2\\Symplify\MonorepoBuilder\\', __DIR__ . '/../src');
};
What is wrong with this file? In config above, we've excluded Symplify\MonorepoBuilder\
namespace from being scoped. Yet, here it is scoped:
$services->load('Scoper12HK32J2\\Symplify\MonorepoBuilder\\', __DIR__ . '/../src');
That lead to a bug, when Symfony is loading services that do not exist. We need to fix it:
-$services->load('Scoper12HK32J2\\Symplify\MonorepoBuilder\\', __DIR__ . '/../src');
+$services->load('Symplify\MonorepoBuilder\\', __DIR__ . '/../src');
We could do it manually on every scoping, or we can teach scoper.php
to do it for us via "patchers"
configuration.
Now, this is the hardest part of the php-scoper configuration, so get ready for callables without types:
# scoper.php
use Nette\Utils\Strings;
return [
// scope symfony configs
'whitelist' => [
'Symplify\MonorepoBuilder\*',
],
'patchers' => [
function (string $filePath, string $prefix, string $content): string {
// $filePath is sometimes relative, sometimes absolute
// so always compare the file path with file ends or a regex
if (! str_ends_with($filePath, 'config/config.php')) {
// we only care about config/config.php file here
// if it's anything else, just keep the origin $content
return $content;
}
// remove the prefix
return Strings::replace(
$content,
'#load\(\'' . $prefix . '\\\\Symplify\\\\MonorepoBuilder#',
'load(\'' . 'Symplify\\MonorepoBuilder',
);
},
]
];
Callables in the patchers
key have every scoped file on the input. The file is already scoped so that we can remove unwanted prefixes.
Our callable above has one job - it finds config/config.php
, then it will remove the prefix on load()
method:
-$services->load('Scoper12HK32J2\\Symplify\MonorepoBuilder\\', __DIR__ . '/../src');
+$services->load('Symplify\MonorepoBuilder\\', __DIR__ . '/../src');
That's it. Now the Symfony autodiscovery works again.
We've already unscoped the Symplify\MonorepoBuilder\
namespace. The Monorepo Builder provides an interface that developers can implements, register in the monorepo-builder.php
config, and the tool will collect it. It looks like this:
use Symplify\MonorepoBuilder\Release\Contract\ReleaseWorkerInterface;
use PharIo\Version\Version;
final class SomeReleaseWorker implements ReleaseWorkerInterface
{
public function work(Version $version)
{
// ...
}
}
Can you see the problem?
The PharIo\Version\Version
is scoped to ScoperDF0239\PharIo\Version\Version
and does not exist. If we try to implement this interface, we will crash:
The PharIo\Version\
namespace is part of our public API. Meaning people can use it because our interface encourages it.
There are 2 solutions to this problem. The first one is more cleaner, but also more work and BC break:
PharIo\Version\Version
in our custom namespaced object, making it a private API use Symplify\MonorepoBuilder\Release\Contract\ReleaseWorkerInterface;
-use PharIo\Version\Version;
+use Symplify\MonorepoBuilder\Version\Version;
final class SomeReleaseWorker implements ReleaseWorkerInterface
{
public function work(Version $version)
{
// ...
}
}
PharIo\Version\*
to unscoped namespace in scoper.php
# scoper.php
return [
// scope symfony configs
'whitelist' => [
'Symplify\MonorepoBuilder\*',
+ 'PharIo\Version\*',
],
// ...
];
This way, the PharIo\Version\*
classes will be skipped from scoping.
I've picked the latter to save trouble for myself and avoid BC break.
Do you have some public API namespace that developers using your package will try to use? Exclude it in the 'whitelist'
key. Mind the asterisk in the end \*
- it covers all classes in the namespace.
We have the config ready. Now it's time to run the scoper.
/vendor
directory.$RESULT_DIRECTORY=monorepo-builder-scoped
php-scoper.phar add-prefix bin config src vendor composer.json --output-dir "../$RESULT_DIRECTORY" --config scoper.php --force --ansi
Now we have scoped tool in the monorepo-builder-scoped
directory, good job!
So how do we get the scoped version to our users? We have to set up repository architecture first.
The symplify/monorepo-builder
package is developed in:
The scoped version is published in:
The same way we push commits to our repository, we will push scoped code to the remote scoped repository:
cd monorepo-builder-scoper
# add a remote repository
git init
git remote add origin git@github.com:symplify/monorepo-builder.git
# add content and push it
git add .
git push -f
/vendor
DirectoryThe most common mistake I make is missing the /vendor
directory. I scope the project, make it run locally. Everything is fine. Then I push the whole project without its vendor, and it breaks. So don't forget to allow pushing the scoped /vendor
too:
# .gitignore
-/vendor
composer.lock
The whole process above is daunting and error-prone. It must be automated and running in CI, so we know the instant any of our commits break it.
We have it in Symplify GitHub Actions up and running here. The scoping process starts in step 4:
The best way to learn is to copy already working processes. It's essential to have a safety net if you're doing something the first time, just before you have an a-ha moment and everything clicks together.
That's why you can learn of the full scoping in symplify/monorepo-builder
that is spread across 3 files.
Keep it up, and I look forward to your first scopes!
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!