Initializing objects with CLI and the power of Symfony Console

Writing CLI commands is fun and eases our workflows. For the MsgPHP project I was challenged with the requirement of creating a user:create command. That shouldn’t be too hard, right? Well … yes and no.

No, because the steps are really simple:

  • Initialize a User object from CLI options and arguments
  • Save it
  • Display a success message

And yes, because (from a vendor perspective) we don’t know anything about your User entity, except the class name. So the first step, initializing it, requires actual context to do so. Given your constructor method signature is defined/overridden at the application level.

The latter may imply we can’t built a generic user:create command, as its logic is tied to the application level. Allthough I think shipping standard (web) controllers is too opinionated, I do want to ship standard CLI commands as much as possible. For example the user:create command always does the above 3 steps, it would be tedious to create it per application solely because the context differs. Let’s solve the context issue instead!

The goal

Create a pattern to initialize any object from a CLI command

The steps

We continue with the user:create example and a User object that looks like:

namespace App;
class User
{
public function __construct(
string $email,
string $password,
bool $enabled = false,
array $roles = ['ROLE_USER'],
Profile $profile = null
) {
// ...
}
}
class Profile
{
public function __construct(string $firstName, string $lastName)
{
// ...
}
}

(The constructor arguments on above classes are just an example for demo purposes)

From that I made up a pattern that should do the following steps:

  • Collect the constructor arguments
  • For each optional argument add a CLI option
  • For each required argument add a CLI argument
  • If a required argument is not given, ask it interactively
  • Context might differ per argument, i.e. $password should be asked hidden whereas a boolean argument could leverage a special confirmation question type. Also we need a label, description etc. per CLI option / argument.

The code

All code required by the demo can be installed using:

composer require msgphp/domain
  • Collect class method arguments using ClassMethodResolver
  • Build a context array from any class method using ClassContextBuilder
  • Customize context elements using ContextElementProviderInterface

Our demo CLI command practically looks like:

namespace App;
use MsgPhp\Domain\Infra\Console\ContextBuilder\ClassContextBuilder;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class DemoCommand extends Command
{
private $contextBuilder;
    public function __construct()
{
$this->contextBuilder = new ClassContextBuilder(
User::class,
'__construct'
);
        parent::__construct();
}
    protected function configure()
{
$this->setName('demo');
$this->contextBuilder->configure($this->getDefinition());
}
    protected function execute(
InputInterface $input,
OutputInterface $output
) {
$context = $this->contextBuilder->getContext(
$input,
new SymfonyStyle($input, $output)
);
        dump($context);
        if (isset($context['profile'])) {
$context['profile'] = new Profile(
...array_values($context['profile'])
);
}
        dump(new User(...array_values($context)));
}
}

(Note dump() requires symfony/var-dumper)

The demo

First let’s see what happens by default:

Mission achieved I guess 😃 The command signature (without Symfony’s default options) looks like:

demo [--enabled] [--roles [ROLES]] [--profile [PROFILE]] [--] [<email>] [<password>]

Yep, works like a charm. Let’s see if we can set the user Profile:

But what if we leave out the (required) last name?

Yeah, I got ya 😉

Now, let’s focus on the email and password fields. We want email to be labeled as “E-mail” while the password field is a bit more complex:

  • Should be hidden when asked
  • Can generate a default value (random password)
  • Should be hashed

For that we need to update the CLI command as follow:

$this->contextBuilder = new ClassContextBuilder(
User::class,
'__construct',
[new MyContextElementProvider()]
);

And the corresponding “context element provider”:

namespace App;
use MsgPhp\Domain\Infra\Console\ContextBuilder\ContextElementProviderInterface;
use MsgPhp\Domain\Infra\Console\ContextBuilder\ContextElement;
class MyContextElementProvider
implements ContextElementProviderInterface
{
public function getElement(
string $class,
string $method,
string $argument
): ?ContextElement {
if ('email' === $argument) {
return new ContextElement('E-mail');
}

if (User::class === $class && 'password' === $argument) {
return new ContextElement(
'Password',
'Some description',
function (string $value, array $context) {
return password_hash($value, \PASSWORD_DEFAULT);
},
function () {
return bin2hex(random_bytes(8));
},
true // hidden
);
}

return null;
}
}

Should be good! Back to the console and check out the new default behavior:

Sweet! But did I fill in a password? The next example should tell:

Hence:

Writing CLI commands is fun

Final words

The MsgPHP demo application is now updated with a standard user:create CLI command! See the related code here and here. The actual command can be found here.

As always; feel free to join forces and improve whatever needs improvement.

That’s it. Cheers!