How to build a CLI command?¶
You can create a new command with the "make:command" command of the "edu" command.
Warning
Warning: this feature is available from v2.0
Why create a CLI command?¶
A CLI command turns an action of your application into a tool that runs directly in the terminal, without going through the browser. It is useful to:
- Automate a repetitive task: generate files (like
make:controller), clear a cache (cache:clear), fill a demo database, run a scheduled job... - Make an action scriptable: commands can be chained in a shell script, a cron job or a CI pipeline — the framework itself works this way (
php bin/edu cache:clearthenphp bin/edu start) - Provide a self-documenting interface:
php bin/edu liste --helpdisplays the description and options of the command, without reading its source code - Check the result: a command returns a code (
Command::SUCCESSorCommand::FAILURE) that the calling script can test
The CLI, the natural interface of AI agents¶
An AI agent cannot click in a graphical interface; on the other hand, it is perfectly able to run a command, read its output and react to its exit code. A well-designed command makes your application drivable by an agent:
| Principle | What it changes for the agent |
|---|---|
A clear description (#[AsCommand] attribute) |
The agent discovers the command in the --help and knows when to use it |
Explicit options (--json) |
The agent gets structured output, with no formatting to filter out |
| A correct exit code | The agent knows whether the action succeeded and can decide what to do next |
| Clean output in machine mode | Nothing to parse: no banner, no colors, no extra text |
This is the philosophy behind make:entity --fields "nom:string,code_postal:string": a described command, predictable options — usable both by a developer at the keyboard and by an agent in a script.
The steps to create a new command¶
The command creates the app/Command/VilleListCommand.php file and automatically registers the class in the app/Config/commands.yaml file:
The generated skeleton¶
<?php
namespace Command;
use Studoo\EduFramework\Commands\Extends\CommandBanner;
use Studoo\EduFramework\Core\ConfigCore;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[\Symfony\Component\Console\Attribute\AsCommand(name: 'villelist', description: 'Renseigner la description de la commande villelist')]
class VilleListCommand extends \Studoo\EduFramework\Commands\Extends\CommandManage
{
public function execute(InputInterface $input, OutputInterface $output): int
{
self::$stdOutput->writeln([
CommandBanner::getBanner(),
'Bienvenue dans la console ' . ConfigCore::getConfig('name'),
'',
]);
// Add your code here
return Command::SUCCESS;
}
}
The skeleton works as is:
The command name
It is the #[AsCommand(name: '...')] attribute that defines the name typed after php bin/edu — the key in the commands.yaml file is only a registration identifier.
A concrete example: a command readable by both humans and agents¶
Goal: a ville:list command that displays the cities in the ville table (the one from the CRUD) — as a table for a human, as JSON for a script or an AI agent.
Completing the skeleton¶
<?php
namespace Command;
use Studoo\EduFramework\Commands\Extends\CommandManage;
use Studoo\EduFramework\Core\ConfigCore;
use Studoo\EduFramework\Core\Service\DatabaseService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'ville:list',
description: 'Lists the cities stored in the database'
)]
class VilleListCommand extends CommandManage
{
protected function configure(): void
{
// --json option: structured output for scripts and AI agents
$this->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format');
}
public function execute(InputInterface $input, OutputInterface $output): int
{
// A command checks its prerequisites and fails cleanly
if (ConfigCore::getEnv('DB_HOST_STATUS') !== 'true') {
self::$stdOutput->error('The database is disabled (DB_HOST_STATUS=false in the .env file)');
return Command::FAILURE;
}
$villes = DatabaseService::getConnect()
->query('SELECT id, nom, code_postal, nombre_habitant FROM ville')
->fetchAll(\PDO::FETCH_ASSOC);
// Machine output: nothing but the JSON to parse (no banner, no colors)
if ($input->getOption('json') === true) {
$output->writeln(json_encode($villes));
return Command::SUCCESS;
}
// Human output: formatted table
self::$stdOutput->title('List of cities');
self::$stdOutput->table(
['ID', 'Name', 'Postal code', 'Inhabitants'],
array_map(
static fn (array $ville): array => [
$ville['id'], $ville['nom'], $ville['code_postal'], $ville['nombre_habitant'],
],
$villes
)
);
return Command::SUCCESS;
}
}
The key points:
- the invocation name comes from the
#[AsCommand]attribute (hereville:list, in the spirit ofmake:entityorcache:clear) configure()declares the--jsonoption: it automatically appears in the command's--help- the exit code (
Command::FAILUREif the database is disabled) lets a script react without parsing the text - in
--jsonmode, no formatting is added: the caller parses the output directly
Testing the command¶
Human output:
List of cities
==============
ID Name Postal code Inhabitants
1 Paris 75000 2200000
2 Marseille 13000 800000
3 Lyon 69000 500000
Output for a script or an agent:
[{"id":"1","nom":"Paris","code_postal":"75000","nombre_habitant":"2200000"},{"id":"2","nom":"Marseille","code_postal":"13000","nombre_habitant":"800000"},{"id":"3","nom":"Lyon","code_postal":"69000","nombre_habitant":"500000"}]
Use by an agent¶
With this command, an AI agent can answer a factual question without opening a single PHP file:
Question asked to the agent: "How many inhabitants do the stored cities have?"
Agent: runs
php bin/edu ville:list --json, reads the JSON and answers: "3 cities are stored: Paris (2,200,000 inhabitants), Marseille (800,000) and Lyon (500,000)."
The winning deal: you document once (description + options + exit code), and the tool becomes usable by a human in the terminal, a cron script, a CI pipeline... or an AI agent.
Going further¶
Symfony documentation