├── .gitignore ├── src └── Controller │ ├── ConsoleAwareInterface.php │ ├── ReportController.php │ ├── GreetingController.php │ └── AbstractConsoleController.php ├── bin └── run ├── config ├── routes.php └── app.php ├── composer.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | composer.lock 3 | vendor 4 | -------------------------------------------------------------------------------- /src/Controller/ConsoleAwareInterface.php: -------------------------------------------------------------------------------- 1 | run(); 11 | -------------------------------------------------------------------------------- /config/routes.php: -------------------------------------------------------------------------------- 1 | addControllerRoute('my-app_greeting', GreetingController::class . ':call') 6 | ->via('GET') 7 | ->name('greeting'); 8 | 9 | $app->addControllerRoute('my-app_gen-report', ReportController::class . ':call') 10 | ->via('GET') 11 | ->name('generate-report'); 12 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "acelaya/slim-cli-example", 3 | "description": "How to use Slim framework to dipatch complex CLI requests", 4 | "require": { 5 | "slim/slim": "^2.6", 6 | "slimcontroller/slimcontroller": "^0.4.3", 7 | "acelaya/slim-container-sm": "^0.1.3", 8 | "zendframework/zend-servicemanager": "^2.6", 9 | "nategood/commando": "dev-master#700b0f667d76e5257996f02470e5f7a4bc2c0a01", 10 | "league/climate": "^3.2" 11 | }, 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Alejandro Celaya", 16 | "email": "alejandro@alejandrocelaya.com" 17 | } 18 | ], 19 | "autoload": { 20 | "psr-4": { 21 | "Acelaya\\SlimCli\\": "src" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Controller/ReportController.php: -------------------------------------------------------------------------------- 1 | cliReader->option() 12 | ->alias('id') 13 | ->describedAs('The client ID. Must be a number.') 14 | ->must(function ($argument) { 15 | return is_numeric($argument); 16 | }) 17 | ->require(); 18 | } 19 | 20 | /** 21 | * This method is called at route dispatch 22 | */ 23 | public function callAction() 24 | { 25 | $id = $this->cliReader['id']; 26 | $this->cliWriter->out(sprintf('Generating report for client with id %s', $id)); 27 | for ($i = 0; $i < 20; $i++) { 28 | $this->cliWriter->inline('.'); 29 | } 30 | $this->cliWriter->out(''); 31 | $this->cliWriter->green()->bold()->out('Success!!'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Controller/GreetingController.php: -------------------------------------------------------------------------------- 1 | cliReader->option('name') 12 | ->describedAs('The name to be displayed in the greeting') 13 | ->require(); 14 | 15 | $this->cliReader->option('uppercase') 16 | ->aka('u') 17 | ->describedAs('If present, it will display the greetings in uppercase') 18 | ->boolean(); 19 | } 20 | 21 | /** 22 | * This method is called at route dispatch 23 | */ 24 | public function callAction() 25 | { 26 | $pattern = 'Hello %s!!'; 27 | $capitalized = $this->cliReader['uppercase']; 28 | $greeting = sprintf($pattern, $this->cliReader['name']); 29 | 30 | $this->cliWriter->green()->out($capitalized ? strtoupper($greeting) : $greeting); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alejandro Celaya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/Controller/AbstractConsoleController.php: -------------------------------------------------------------------------------- 1 | cliReader = $command ?: new Command(); 25 | $this->cliWriter = $climate ?: new CLImate(); 26 | 27 | // Define the first mandatory command 28 | $currentCommand = $this->app->router()->getCurrentRoute()->getPattern(); 29 | $this->cliReader->option() 30 | ->require() 31 | ->describedAs('The command to execute') 32 | ->must(function ($command) use ($currentCommand) { 33 | return $currentCommand === $command; 34 | }); 35 | $this->initCommand(); 36 | } 37 | 38 | /** 39 | * This method is called at route dispatch 40 | */ 41 | abstract public function callAction(); 42 | } 43 | -------------------------------------------------------------------------------- /config/app.php: -------------------------------------------------------------------------------- 1 | false, // Turn off Slim's own PrettyExceptions 15 | 'controller.class_prefix' => '', 16 | 'controller.class_suffix' => '', 17 | 'controller.method_suffix' => 'Action', 18 | 'controller.template_suffix' => '', 19 | ]); 20 | 21 | // Set up the environment so that Slim can route 22 | $app->environment = Slim\Environment::mock([ 23 | 'PATH_INFO' => $pathinfo 24 | ]); 25 | 26 | // Define the help command. If it doesn't have a name it won't include itself 27 | $app->get('--help', function () use ($app) { 28 | $writer = new CLImate(); 29 | $writer->bold()->out('Available commands:'); 30 | foreach ($app->router()->getNamedRoutes() as $route) { 31 | $writer->green()->out(' ' . $route->getPattern()); 32 | } 33 | }); 34 | // CLI-compatible not found error handler 35 | $app->notFound(function () use ($app) { 36 | $writer = new CLImate(); 37 | $command = $app->environment['PATH_INFO']; 38 | $writer->red()->bold()->out(sprintf('Error: Cannot route to command "%s"', $command)); 39 | $helpRoute = $app->router()->getMatchedRoutes('GET', '--help', true); 40 | $helpRoute[0]->dispatch(); 41 | $app->stop(); 42 | }); 43 | 44 | // Format errors for CLI 45 | $app->error(function (\Exception $e) use ($app) { 46 | echo $e; 47 | echo PHP_EOL; 48 | $app->stop(); 49 | }); 50 | 51 | return $app; 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slim CLI 2 | 3 | How to use Slim framework to dipatch complex CLI requests 4 | 5 | ### Catching console requests 6 | 7 | Slim does not support dispatching non-http requests (like console requests), so they need to be faked. 8 | 9 | That can be easily done by creating a mock environment and overwriting Slim's one. 10 | 11 | For example, let's assume this is our `bin/run` script. 12 | 13 | ```php 14 | #!/usr/bin/env php 15 | environment = Slim\Environment::mock([ 24 | 'PATH_INFO' => $pathinfo 25 | ]); 26 | 27 | // [...] Define help command and error management 28 | 29 | $app->get('foo_bar', function () { 30 | echo 'Hello!!'; 31 | }); 32 | 33 | $app->run(); 34 | ``` 35 | 36 | It catches the first argument and maps it to a route path, making Slim to think this is an HTTP request. If no argument is provided, it maps it to a `--help` path that will be used to display available commands. 37 | 38 | So, if we want to run the foo_bar route, we will have tu execute this command. 39 | 40 | ```bash 41 | bin/run foo_bar 42 | ``` 43 | 44 | It will print "Hello!!". 45 | 46 | ### Console helpers 47 | 48 | That's fine, but it will be hard to manage complex arguments. We could define ordered route arguments, but we'll be forced to define them as string and provide them always in the same order. 49 | 50 | To fix this, we are going to use the [nategood/commando](https://github.com/nategood/commando) package, that's a simple yet powerful CLI helper to manage arguments. 51 | 52 | With it, we will be able to define flags and named params that doesn't need to be defined in a specific order, and also value validators. 53 | 54 | On the other hand, we will need to be able to write formatted output, in order to provide feedback or print help instructions. For that purpose we are going to use the [phpleague/climate](http://climate.thephpleague.com/basic-usage/) package. 55 | 56 | ### Console actions 57 | 58 | We will need every command to be mapped to an action, and the `Commando\Command` object to be properly configured for each specific case. 59 | 60 | For that purpose, we can define controllers extending the `AbstractConsoleController`. It initializes the `Command` object to accept a first required command and defines an abstract `initCommand` method that will be called in order to customize the rest of the arguments. 61 | 62 | Since every command will be different, we won't be able to define more than one action per controller, unless their arguments are exactly the same (or we set conditionals in the `initCommand` method implementation). 63 | 64 | The `AbstractConsoleController` constructor also initializes the `CLImate` object. This is how it looks like. 65 | 66 | ```php 67 | public function __construct(Slim $app, Command $command = null, CLImate $climate = null) 68 | { 69 | parent::__construct($app); 70 | $this->cliReader = $command ?: new Command(); 71 | $this->cliWriter = $climate ?: new CLImate(); 72 | 73 | // Define the first mandatory command 74 | $currentCommand = $this->app->router()->getCurrentRoute()->getPattern(); 75 | $this->cliReader->option() 76 | ->require() 77 | ->describedAs('The command to execute') 78 | ->must(function ($command) use ($currentCommand) { 79 | return $currentCommand === $command; 80 | }); 81 | $this->initCommand(); 82 | } 83 | ``` 84 | 85 | And a concrete implementation, like the `GreetingController`, could look like this 86 | 87 | ```php 88 | /** 89 | * Initializes the command 90 | */ 91 | public function initCommand() 92 | { 93 | $this->cliReader->option('name') 94 | ->describedAs('The name to be displayed in the greeting') 95 | ->require(); 96 | 97 | $this->cliReader->option('uppercase') 98 | ->aka('u') 99 | ->describedAs('If present, it will display the greetings in uppercase') 100 | ->boolean(); 101 | } 102 | 103 | /** 104 | * This method is called at route dispatch 105 | */ 106 | public function callAction() 107 | { 108 | $pattern = 'Hello %s!!'; 109 | $capitalized = $this->cliReader['uppercase']; 110 | $greeting = sprintf($pattern, $this->cliReader['name']); 111 | 112 | $this->cliWriter->green()->out($capitalized ? strtoupper($greeting) : $greeting); 113 | } 114 | ``` 115 | 116 | We could register this command as a Slim route like this: 117 | 118 | ```php 119 | $app->addControllerRoute('my-app_greeting', GreetingController::class . ':call') 120 | ->via('GET') 121 | ->name('greeting'); 122 | ``` 123 | 124 | And finally, this action would be dispatched by running any of these commands. 125 | 126 | ```bash 127 | > bin/run my-app_greeting --name "Alejandro Celaya" 128 | # This would print "Hello Alejandro Celaya!!" 129 | 130 | > bin/run my-app_greeting --name "Alejandro Celaya" --uppercase 131 | # This would print "HELLO ALEJANDRO CELAYA!!" 132 | 133 | > bin/run my-app_greeting --name "Alejandro Celaya" -u 134 | # This would print "HELLO ALEJANDRO CELAYA!!" 135 | 136 | > bin/run my-app_greeting --uppercase --name "Alejandro Celaya" 137 | # This would print "HELLO ALEJANDRO CELAYA!!" 138 | ``` 139 | 140 | ### General help and command-specific help 141 | 142 | Since it is hard to remember all the available commands and their signature, it is very usefull to have "help" commands. 143 | 144 | The `Command` class comes with a built in `--help` param that displays a human-friendly help for certain command. 145 | 146 | So, if we want to see the *greeting* command help, we just need to run this: 147 | 148 | ```bash 149 | > bin/run my-app_greeting --help 150 | # This would print a nice human-friendly help 151 | ``` 152 | 153 | But we still need a way to get the list of available commands. 154 | 155 | We can take advantage of Slim's error management, and use the notFound error to display the available commands, and also define a --help command that does the same. 156 | 157 | ```php 158 | // Define the help command. If it doesn't have a name it won't include itself 159 | $app->get('--help', function () use ($app) { 160 | $writer = new CLImate(); 161 | $writer->bold()->out('Available commands:'); 162 | foreach ($app->router()->getNamedRoutes() as $route) { 163 | $writer->green()->out(' ' . $route->getPattern()); 164 | } 165 | }); 166 | // CLI-compatible not found error handler 167 | $app->notFound(function () use ($app) { 168 | $writer = new CLImate(); 169 | $command = $app->environment['PATH_INFO']; 170 | $writer->red()->bold()->out(sprintf('Error: Cannot route to command "%s"', $command)); 171 | 172 | // Dispatching the "help" route will print the available commands in addition to the error 173 | $helpRoute = $app->router()->getMatchedRoutes('GET', '--help', true); 174 | $helpRoute[0]->dispatch(); 175 | 176 | $app->stop(); 177 | }); 178 | ``` 179 | 180 | Once this is configured we can run any of these commands to display the help. 181 | 182 | ```bash 183 | > bin/run --help 184 | # Will display the list of valid commands 185 | 186 | > bin/run 187 | # Will display the list of valid commands too, since we configured that any empty command is mapped to the --help command 188 | 189 | > bin/run something_invalid 190 | # Will display an error because this is an invalid command 191 | # Then, it will display thelist of valid commands 192 | ``` 193 | 194 | ### Best practices 195 | 196 | We have to follow some kind of convention in order to prevent duplicated commands. 197 | 198 | The symfony and doctrine people usually recommend to namespace commands by using the colon character, this way instead of having the `greeting` command, which is too common and easy to be duplicated, they prefix some kind of vendor name followed by a colon, `package:greeting`. 199 | 200 | This is not possible with Slim, since the commands are used as route patterns, and Slim uses a regular expressions that does weird things when the pattern contains colons. 201 | 202 | Instead we can use underscores, like in the examples, `my-app_greeting`. 203 | 204 | ### Testing this in a real environment 205 | 206 | This project is a real application where you can test what we have just explained. 207 | 208 | Clone this repository, run `composer install` to get everything installed and run the command `bin/run` to play with the available commands. 209 | --------------------------------------------------------------------------------