I'm working on ChangelogLinker, a package that makes managing CHANGELOG.md
very easy - it generates it. It a CLI Application with a 3 Console Commands. All was good, until I needed to add an argument to all commands at once... and in lazy, extensible, maintainable way.
Why? Symplify CHANGELOG.md
was growing and growing, keeping upgrade data about 3 major versions. Then I realized there can be more CHANGELOG.md
files, right?
At that time, the path to file was hardcoded as getcwd() . '/CHANGELOG.md'
, so each command worked only with that file:
vendor/bin/changelog dump-merges
vendor/bin/changelog link
vendor/bin/changelog cleanup
But I needed to change the file:
vendor/bin/changelog dump-merges CHANGELOG.md
vendor/bin/changelog link CHANGELOG-2.md
vendor/bin/changelog cleanup CHANGELOG-3.md
We need to add global file argument. So, what option do we have?
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
final class LinkCommand extends Command
{
protected function configure(): void
{
// ...
+ $this->addArgument('file', InputArgument::OPTIONAL, 'Path to changelog file to work wiht');
}
}
final class DumpMergesCommandCommand extends Command
{
protected function configure(): void
{
// ...
+ $this->addArgument('file', InputArgument::OPTIONAL, 'Path to changelog file to work wiht');
}
}
final class LinkCommand extends Command
{
protected function configure(): void
{
// ...
+ $this->addArgument('file', InputArgument::OPTIONAL, 'Path to changelog file to work wiht');
}
}
Good for creating & sell applications, bad for projects you want to work on for a couple of years.
I'll tell you a secret. There is one place you can modify definition not just for active command, but for the whole application - it's Application Definition!
The first simple & short solution you'd Googled up is to modify it in bin file:
<?php
use Symplify\EasyCodingStandard\Console\Application;
use Symfony\Component\Console\Input\InputArgument;
$application = $container->get(Application::class);
+$applicationDefinition = $application->getDefinition();
+$applicationDefinition->addArguments([
+ new InputArgument('file', InputArgument::OPTIONAL, 'Path to changelog file to work wiht');
+]);
$application->run();
we program outside the Application - when we get the application somewhere else (e.g. tests), it might break
$application = $container->get(Application::class);
$application->run();
// passing `CHANGELOG-2.md` as argument → invalid argument error
that's why the should be encapsulated - always prefer tree dependencies over these circle ones
I found this approach on Matthias Noback's blog. The process is similar to above, just wrapped in event subscriber that hooks into the Console Application cycle:
<?php
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Console\ConsoleEvents;
final class FileArgumentEventSubscriber implements EventSubscriberInterface
{
/**
* @return string[]
*/
public static function getSubscribedEvents(): array
{
return [ConsoleEvents::COMMAND => 'onConsoleCommand'];
}
public function onConsoleCommand(ConsoleCommandEvent $event): void
{
$applicationDefinition = $event->getCommand()->getApplication()->getDefinition();
$applicationDefinition->addArguments([
new InputArgument('file', InputArgument::OPTIONAL, 'Path to changelog file to work with')
]);
}
}
There are now new memory-locks, that not really needed:
composer require symfony/event-dispatcher
Also, would you add routes this way?
class SomeController
{
public function someAction(Request $request)
{
$router = $request->getAttribute('controller')->getContainer()->get('router');
$router->addRoute('...');
}
}
Above, we ask event to get a service, to invoke a callback on another service. When you ask event (unique object) for a service (global class), there is something wrong. Events should work with unique information - they're value objects after all.
The post is 5 years old and I don't think Matthias still sees this as the best way to go, yet Google shows it in top 5 results. Matthias has a popular and valuable blog (I learned a lot myself back in my early years in Symfony) and some people might think this is the best practice. To add more salt to the wound, this answer also spread to StackOverflow without concurrency.
What is really important? The definition of application, nothing more.
I'm very happy to see that composer code has this right. There is global option --working-dir
, that allows you simply run composer in another directory:
composer update --working-dir projects/open-training
# equals to
cd projects/open-training
composer update
cd ../..
How did I find this out? I needed to remove one of the basic options Symfony Console Application has out of the box:
It took me a while but the track lead to Application::getDefaultInputDefinition()
method.
<?php
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputArgument;
final class SomeApplication extends Application
{
+ protected function getDefaultInputDefinition()
+ {
+ $definition = parent::getDefaultInputDefinition();
+ $definition->addArgument(new InputArgument('file', InputArgument::OPTIONAL, 'Path to changelog file to work with'));
+
+ return $definition;
+ }
}
Symfony\Component\Console\Application
is one of very few classes I'd allow to extend. It's just 1:1 = easy to maintain and change. Not like entity repository, that can have dozens of children.
very little known → very hard to discover and debug - we should add a note about this to config.yml
services:
# we extend default definition here with `file` argument
SomePackage\Console\SomeApplication: ~
For all these reasons, this is the one I prefer. Do you want to see it in the real world? Here is a commit from Monorepo Builder for our file
argument.
This post is not just about adding an option/argument to console. It's about applying the best choice in every feature you add. And if you don't know, look for it. Don't just blindly take the first result provided Google. It might be popular, widespread, but that doesn't mean it's high quality and valid solution.
Do you see similar anti-patterns as 1, 2 or 3 in your code you're now happy with? How could you make it better?
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!