'Create Artisan command with option that must specify a value

Laravel's documentation says (emphasis mine):

If the user must specify a value for an option, you should suffix the option name with a = sign…

But then goes on to say:

If the option is not specified when invoking the command, its value will be null

Which suggests that "must" doesn't mean what I think it means. And indeed that is the case. A simple command with a signature like this:

protected $signature = "mycommand {-t|test=}";

Will run just fine when called like artisan mycommand -t. And what's worse is that if you specify a default value, it isn't applied in this case.

protected $signature = "mycommand {-t|test=42}";

When running artisan mycommand, $this->option('test') will give you a value of 42, but when run as artisan mycommand -t it gives a value of null.

So, is there a way to require that a user must (actually) specify a value for a given option, if it's present on the command line?



Solution 1:[1]

Poking around the Laravel code, I confirmed that there is no way to have a truly "required" value. Although Symfony does provide for required values, Laravel doesn't use this capability. Instead the options are all created as optional, so I will have to write my own parser...

This was fairly straightforward; I had to write a custom parser class to override the Illuminate\Console\Parser::parseOption() method, and then override Illuminate\Console\Command::configureUsingFluentDefinition() to use that new class.

I elected to create a new option type, rather than change the behaviour of any existing command options. So now I declare my signature like this when I want to force a value:

<?php

namespace App\Console\Commands;

use App\Console\Command;

class MyCommand extends Command
{
    /** @var string The double == means a required value */
    protected $signature = "mycommand {--t|test==}";

    ...
}

Attempting to run artisan mycommand -t will now throw a Symfony\Component\Console\Exception\RuntimeException with a message of "The --test option requires a value." This also works for array options (--t==*) and/or options with default values (--t==42 or --t==*42.)

Here's the code for the new parser class:

<?php

namespace App\Console;

use Illuminate\Console\Parser as BaseParser;
use Symfony\Component\Console\Input\InputOption;

class Parser extends BaseParser
{
    protected static function parseOption($token): InputOption
    {
        [$mytoken, $description] = static::extractDescription($token);

        $matches = preg_split("/\\s*\\|\\s*/", $mytoken, 2);

        if (isset($matches[1])) {
            $shortcut = $matches[0];
            $mytoken = $matches[1];
        } else {
            $shortcut = null;
        }

        switch (true) {
            case str_ends_with($mytoken, "=="):
                return new InputOption(
                    trim($mytoken, "="),
                    $shortcut,
                    InputOption::VALUE_REQUIRED,
                    $description
                );
            case str_ends_with($mytoken, "==*"):
                return new InputOption(
                    trim($mytoken, "=*"),
                    $shortcut,
                    InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
                    $description
                );
            case preg_match("/(.+)==\*(.+)/", $mytoken, $matches):
                return new InputOption(
                    $matches[1],
                    $shortcut,
                    InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
                    $description,
                    preg_split('/,\s?/', $matches[2])
                );
            case preg_match("/(.+)==(.+)/", $mytoken, $matches):
                return new InputOption(
                    $matches[1],
                    $shortcut,
                    InputOption::VALUE_REQUIRED,
                    $description,
                    $matches[2]
                );
            default:
                // no == here, fall back to the standard parser
                return parent::parseOption($token);
        }
    }
}

And the new command class:

<?php

namespace App\Console;

use Illuminate\Console\Command as BaseCommand;

class Command extends BaseCommand
{
    /**
     * Overriding the Laravel parser so we can have required arguments
     *
     * @inheritdoc
     * @throws ReflectionException
     */
    protected function configureUsingFluentDefinition(): void
    {
        // using our parser here
        [$name, $arguments, $options] = Parser::parse($this->signature);

        // need to call the great-grandparent constructor here; probably
        // could have hard-coded to Symfony, but better safe than sorry
        $reflectionMethod = new ReflectionMethod(
            get_parent_class(BaseCommand::class),
            "__construct"
        );
        $reflectionMethod->invoke($this, $name);

        $this->getDefinition()->addArguments($arguments);
        $this->getDefinition()->addOptions($options);
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1