Sviluppare applicazioni CLI in PHP
PHP viene associato quasi sempre al web, eppure chi lavora con Laravel o Symfony usa ogni giorno applicazioni da riga di comando scritte in PHP: artisan, bin/console, Composer, PHPUnit, PHPStan. L'interprete CLI di PHP è veloce, non ha i limiti di tempo e di memoria tipici dell'ambiente web e può contare su un ecosistema maturo. In questo articolo costruiremo tasks, un gestore di attività con persistenza su file JSON, usando il componente symfony/console, lo standard di fatto per le CLI in PHP, e cureremo ogni aspetto che distingue uno strumento professionale: codici di uscita, separazione tra output ed errori, scrittura atomica, test e distribuzione come archivio PHAR.
Le convenzioni da rispettare
Uno strumento da riga di comando deve comportarsi come gli altri programmi del sistema:
- i risultati vanno su standard output, errori e diagnostica su standard error;
- il codice di uscita vale
0in caso di successo,1per gli errori di esecuzione e2per gli errori di utilizzo; Symfony Console definisce proprio queste tre costanti,Command::SUCCESS,Command::FAILUREeCommand::INVALID; - i colori si usano solo su terminale e mai se
NO_COLORè impostata; - è disponibile un formato JSON per l'uso da script.
tasks add "Rinnovare il certificato TLS" --priority=high
tasks list --all --format=json
tasks done 3
tasks rm 3
Il SAPI CLI e la funzione getopt
Prima di introdurre Symfony Console vale la pena ricordare cosa offre PHP da solo. Quando uno script viene eseguito da terminale, PHP usa il SAPI cli: gli argomenti sono disponibili in $argv, i flussi standard nelle costanti STDIN, STDOUT e STDERR, e il codice di uscita si imposta con exit(). Per il parsing esiste la funzione getopt:
<?php
// Analisi minimale con getopt: utile per script, insufficiente per una CLI vera.
$options = getopt('p:h', ['priority:', 'help'], $restIndex);
$positionals = array_slice($argv, $restIndex);
if (isset($options['h']) || isset($options['help'])) {
fwrite(STDOUT, "Uso: script.php [--priority=livello] <titolo>\n");
exit(0);
}
if ($positionals === []) {
fwrite(STDERR, "script.php: il titolo è obbligatorio\n");
exit(2);
}
getopt però non segnala le opzioni sconosciute, che vengono semplicemente ignorate, non conosce i sottocomandi e non genera l'aiuto. Per un'applicazione reale serve qualcosa di più strutturato.
Struttura del progetto
tasks/
├── composer.json
├── box.json
├── bin/
│ └── tasks
├── src/
│ ├── Command/
│ │ ├── AddCommand.php
│ │ ├── DoneCommand.php
│ │ ├── ListCommand.php
│ │ └── RemoveCommand.php
│ ├── Priority.php
│ ├── Store.php
│ └── StoreException.php
└── tests/
└── CliTest.php
{
"name": "example/tasks",
"description": "Un semplice gestore di attività da riga di comando",
"type": "project",
"license": "MIT",
"require": {
"php": ">=8.2",
"symfony/console": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": { "Tasks\\": "src/" }
},
"autoload-dev": {
"psr-4": { "Tasks\\Tests\\": "tests/" }
},
"bin": ["bin/tasks"]
}
Il campo bin indica a Composer quali file sono eseguibili: quando il pacchetto viene installato, globalmente o come dipendenza, Composer crea un collegamento in vendor/bin o nella directory globale degli eseguibili.
composer install
Il modello dei dati e la persistenza
Le enumerazioni di PHP 8.1 sono perfette per rappresentare la priorità: il metodo tryFrom restituisce null per un valore non valido, rendendo la validazione banale.
<?php
declare(strict_types=1);
namespace Tasks;
enum Priority: string
{
case Low = 'low';
case Normal = 'normal';
case High = 'high';
/** @return list<string> */
public static function values(): array
{
return array_map(static fn (self $p): string => $p->value, self::cases());
}
}
<?php
declare(strict_types=1);
namespace Tasks;
/** Errore di esecuzione legato ai dati (codice di uscita 1). */
final class StoreException extends \RuntimeException
{
public static function notFound(int $id): self
{
return new self(sprintf('attività non trovata: %d', $id));
}
}
La classe Store legge e scrive il file JSON. Il percorso predefinito rispetta le convenzioni della piattaforma, con la variabile TASKS_FILE che ha la precedenza su tutto.
<?php
declare(strict_types=1);
namespace Tasks;
final class Store
{
public function __construct(private readonly string $path)
{
}
public static function defaultPath(): string
{
$override = getenv('TASKS_FILE');
if (is_string($override) && $override !== '') {
return $override;
}
if (PHP_OS_FAMILY === 'Windows') {
$base = getenv('APPDATA') ?: getenv('USERPROFILE') . '\\AppData\\Roaming';
} elseif (PHP_OS_FAMILY === 'Darwin') {
$base = getenv('HOME') . '/Library/Application Support';
} else {
$base = getenv('XDG_DATA_HOME') ?: getenv('HOME') . '/.local/share';
}
return $base . DIRECTORY_SEPARATOR . 'tasks' . DIRECTORY_SEPARATOR . 'tasks.json';
}
/** @return list<array{id:int,title:string,priority:string,done:bool,created_at:string}> */
public function load(): array
{
if (!file_exists($this->path)) {
return [];
}
$raw = @file_get_contents($this->path);
if ($raw === false) {
throw new StoreException(sprintf('impossibile leggere %s', $this->path));
}
try {
$tasks = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new StoreException(sprintf('il file %s non è valido: %s', $this->path, $e->getMessage()), 0, $e);
}
if (!is_array($tasks) || !array_is_list($tasks)) {
throw new StoreException(sprintf('il file %s non contiene un elenco di attività', $this->path));
}
return $tasks;
}
Il salvataggio è atomico: scriviamo su un file temporaneo nella stessa directory, forziamo lo scaricamento su disco con fsync, disponibile da PHP 8.1, e infine rinominiamo il file temporaneo sopra l'originale. La rinomina all'interno dello stesso filesystem è atomica, quindi un'interruzione non può mai lasciare il file dati a metà.
/** @param list<array<string,mixed>> $tasks */
public function save(array $tasks): void
{
$dir = dirname($this->path);
if (!is_dir($dir) && !@mkdir($dir, 0o755, true) && !is_dir($dir)) {
throw new StoreException(sprintf('impossibile creare la directory %s', $dir));
}
$json = json_encode($tasks, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$tmp = tempnam($dir, '.tasks-');
if ($tmp === false) {
throw new StoreException(sprintf('impossibile creare un file temporaneo in %s', $dir));
}
try {
$handle = fopen($tmp, 'wb');
if ($handle === false || fwrite($handle, $json . "\n") === false || !fsync($handle)) {
throw new StoreException(sprintf('impossibile scrivere %s', $tmp));
}
fclose($handle);
if (!rename($tmp, $this->path)) {
throw new StoreException(sprintf('impossibile sostituire %s', $this->path));
}
} finally {
// Dopo una rinomina riuscita il file temporaneo non esiste più.
if (file_exists($tmp)) {
@unlink($tmp);
}
}
}
/** @param list<array{id:int}> $tasks */
public static function nextId(array $tasks): int
{
return max([0, ...array_column($tasks, 'id')]) + 1;
}
/** @param list<array{id:int}> $tasks */
public static function indexOf(array $tasks, int $id): int
{
foreach ($tasks as $index => $task) {
if ($task['id'] === $id) {
return $index;
}
}
throw StoreException::notFound($id);
}
}
I comandi con Symfony Console
In Symfony Console ogni sottocomando è una classe che estende Command. L'attributo #[AsCommand] dichiara nome, descrizione e alias; il metodo configure definisce argomenti e opzioni; il metodo execute esegue il lavoro e restituisce il codice di uscita. Il componente genera automaticamente l'aiuto di ogni comando, l'elenco dei comandi e i messaggi di errore per argomenti mancanti o opzioni sconosciute.
add
<?php
declare(strict_types=1);
namespace Tasks\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Tasks\Priority;
use Tasks\Store;
#[AsCommand(name: 'add', description: "Aggiunge un'attività")]
final class AddCommand extends Command
{
public function __construct(private readonly Store $store)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('title', InputArgument::REQUIRED | InputArgument::IS_ARRAY, "Titolo dell'attività")
->addOption(
'priority',
'p',
InputOption::VALUE_REQUIRED,
'Priorità: ' . implode(', ', Priority::values()),
Priority::Normal->value,
Priority::values(),
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$title = trim(implode(' ', $input->getArgument('title')));
$priority = Priority::tryFrom((string) $input->getOption('priority'));
if ($priority === null) {
throw new InvalidOptionException(sprintf(
'priorità non valida "%s" (valori ammessi: %s)',
$input->getOption('priority'),
implode(', ', Priority::values()),
));
}
$tasks = $this->store->load();
$task = [
'id' => Store::nextId($tasks),
'title' => $title,
'priority' => $priority->value,
'done' => false,
'created_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(DATE_ATOM),
];
$tasks[] = $task;
$this->store->save($tasks);
$output->writeln(sprintf("Aggiunta l'attività %d: %s", $task['id'], $task['title']));
return Command::SUCCESS;
}
}
Lo store viene iniettato nel costruttore: i comandi non sanno dove si trovi il file dati, e nei test potremo passare uno store che punta a una directory temporanea. L'argomento title è di tipo IS_ARRAY, così che tasks add Comprare il latte funzioni anche senza virgolette. L'ultimo parametro di addOption fornisce i valori suggeriti per il completamento automatico della shell.
Per gli errori di validazione usiamo le eccezioni del componente, come InvalidOptionException: in questo modo tutti gli errori di utilizzo, che siano rilevati da Symfony o dal nostro codice, appartengono alla stessa famiglia, quella che implementa Symfony\Component\Console\Exception\ExceptionInterface. Ci servirà per assegnare loro il codice di uscita corretto.
list
Il comando list sfrutta l'helper Table per l'output tabellare. I tag di formattazione come <fg=red> vengono tradotti in codici ANSI solo quando l'output supporta i colori, e Symfony calcola la larghezza delle colonne ignorando i tag: non dobbiamo preoccuparci dell'allineamento.
<?php
declare(strict_types=1);
namespace Tasks\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Tasks\Store;
#[AsCommand(name: 'list', description: 'Elenca le attività', aliases: ['ls'])]
final class ListCommand extends Command
{
private const COLORS = ['high' => 'red', 'normal' => 'yellow', 'low' => 'gray'];
public function __construct(private readonly Store $store)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('all', 'a', InputOption::VALUE_NONE, 'Include le attività completate')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Formato: table o json', 'table', ['table', 'json']);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$format = $input->getOption('format');
if (!in_array($format, ['table', 'json'], true)) {
throw new InvalidOptionException(sprintf('formato non supportato "%s"', $format));
}
$showAll = (bool) $input->getOption('all');
$tasks = array_values(array_filter(
$this->store->load(),
static fn (array $task): bool => $showAll || !$task['done'],
));
if ($format === 'json') {
// OUTPUT_RAW: nessuna interpretazione dei tag, il JSON esce intatto.
$json = json_encode($tasks, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$output->writeln($json, OutputInterface::OUTPUT_RAW);
return Command::SUCCESS;
}
if ($tasks === []) {
$output->writeln('Nessuna attività.');
return Command::SUCCESS;
}
$table = new Table($output);
$table->setStyle('compact')->setHeaders(['ID', 'STATO', 'PRIORITÀ', 'TITOLO']);
foreach ($tasks as $task) {
$table->addRow([
$task['id'],
$task['done'] ? 'fatta' : 'aperta',
sprintf('<fg=%s>%s</>', self::COLORS[$task['priority']] ?? 'default', $task['priority']),
// I titoli inseriti dall'utente non devono essere interpretati come tag.
OutputFormatter::escape($task['title']),
]);
}
$table->render();
return Command::SUCCESS;
}
}
Due dettagli sono fondamentali. Il primo: l'output JSON viene scritto con OUTPUT_RAW, altrimenti un titolo che contiene qualcosa come <info> verrebbe interpretato come tag di formattazione e il JSON risulterebbe alterato. Il secondo: nella tabella il titolo passa per OutputFormatter::escape per lo stesso motivo, perché i dati inseriti dall'utente non devono mai diventare markup.
done e rm
I due comandi rimanenti condividono la lettura e la validazione dell'ID. Una classe base astratta raccoglie la logica comune.
<?php
declare(strict_types=1);
namespace Tasks\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Tasks\Store;
abstract class TaskIdCommand extends Command
{
public function __construct(protected readonly Store $store)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('id', InputArgument::REQUIRED, "ID dell'attività");
}
protected function taskId(InputInterface $input): int
{
$raw = (string) $input->getArgument('id');
$id = filter_var($raw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($id === false) {
throw new InvalidArgumentException(sprintf('ID non valido "%s"', $raw));
}
return $id;
}
}
<?php
declare(strict_types=1);
namespace Tasks\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Tasks\Store;
#[AsCommand(name: 'done', description: "Segna un'attività come completata")]
final class DoneCommand extends TaskIdCommand
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$id = $this->taskId($input);
$tasks = $this->store->load();
$tasks[Store::indexOf($tasks, $id)]['done'] = true;
$this->store->save($tasks);
$output->writeln(sprintf('Attività %d completata.', $id));
return Command::SUCCESS;
}
}
<?php
declare(strict_types=1);
namespace Tasks\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Tasks\Store;
#[AsCommand(name: 'rm', description: "Elimina un'attività", aliases: ['remove'])]
final class RemoveCommand extends TaskIdCommand
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$id = $this->taskId($input);
$tasks = $this->store->load();
array_splice($tasks, Store::indexOf($tasks, $id), 1);
$this->store->save($tasks);
$output->writeln(sprintf('Attività %d eliminata.', $id));
return Command::SUCCESS;
}
}
Usiamo filter_var con FILTER_VALIDATE_INT invece di un cast: (int) "3abc" restituirebbe silenziosamente 3, mentre filter_var rifiuta qualsiasi stringa che non sia esattamente un intero.
L'applicazione e i codici di uscita
Per impostazione predefinita Application::run cattura ogni eccezione, la mostra in un riquadro su standard error e restituisce il codice dell'eccezione, oppure 1 se il codice è zero. Gli errori di utilizzo finirebbero quindi con codice 1, indistinguibili dagli errori di esecuzione. Per rispettare la convenzione disattiviamo la cattura automatica e decidiamo noi il codice: 2 per le eccezioni del componente Console, 1 per tutte le altre. Il rendering resta quello di Symfony, grazie al metodo pubblico renderThrowable.
Raccogliamo la costruzione dell'applicazione in una classe Kernel, così che lo script eseguibile e i test usino esattamente la stessa configurazione.
<?php
declare(strict_types=1);
namespace Tasks;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Helper\DescriptorHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Tasks\Command\AddCommand;
use Tasks\Command\DoneCommand;
use Tasks\Command\ListCommand;
use Tasks\Command\RemoveCommand;
final class Kernel
{
public const VERSION = '1.0.0';
public static function application(Store $store): Application
{
$app = new Application('tasks', self::VERSION);
$app->addCommands([
new AddCommand($store),
new ListCommand($store),
new DoneCommand($store),
new RemoveCommand($store),
]);
$app->setAutoExit(false);
$app->setCatchExceptions(false);
return $app;
}
/** Esegue l'applicazione e traduce le eccezioni in codici di uscita. */
public static function run(Application $app, InputInterface $input, OutputInterface $output): int
{
$errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
// Senza comando Symfony eseguirebbe il comando predefinito "list", che qui è il nostro:
// mostriamo invece l'elenco dei comandi, su stdout se richiesto con --help.
if ($input->getFirstArgument() === null && !$input->hasParameterOption(['--version', '-V'], true)) {
$help = $input->hasParameterOption(['--help', '-h'], true);
(new DescriptorHelper())->describe($help ? $output : $errorOutput, $app);
return $help ? Command::SUCCESS : Command::INVALID;
}
try {
return $app->run($input, $output);
} catch (ExceptionInterface $e) {
// Errori di utilizzo: comando sconosciuto, argomenti mancanti, opzioni non valide.
$app->renderThrowable($e, $errorOutput);
return Command::INVALID;
} catch (StoreException $e) {
$errorOutput->writeln(sprintf('<error>tasks: %s</error>', OutputFormatter::escape($e->getMessage())));
return Command::FAILURE;
}
}
}
Il controllo iniziale risolve un conflitto sottile. Symfony Console registra un proprio comando list, che elenca i comandi disponibili ed è anche il comando predefinito eseguito quando non se ne specifica nessuno. Il nostro ListCommand lo sostituisce, perché ha lo stesso nome: senza il controllo, tasks invocato da solo elencherebbe le attività ed uscirebbe con 0. Con DescriptorHelper mostriamo invece l'elenco dei comandi, su standard error con codice 2, oppure su standard output con codice 0 se l'utente ha chiesto esplicitamente --help.
Anche qui il messaggio passa per OutputFormatter::escape, così che un percorso o un titolo non venga interpretato come markup. Gli errori dello store vengono mostrati in una forma compatta, sulla falsariga dei comandi Unix, perché si tratta di condizioni previste e non di eccezioni da riportare nel riquadro di debug.
Lo script eseguibile si limita a caricare l'autoloader e delegare al Kernel:
#!/usr/bin/env php
<?php
declare(strict_types=1);
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Tasks\Kernel;
use Tasks\Store;
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "tasks deve essere eseguito da riga di comando\n");
exit(1);
}
// Funziona sia dai sorgenti sia installato come dipendenza Composer.
$autoload = $GLOBALS['_composer_autoload_path'] ?? __DIR__ . '/../vendor/autoload.php';
require $autoload;
$app = Kernel::application(new Store(Store::defaultPath()));
exit(Kernel::run($app, new ArgvInput(), new ConsoleOutput()));
La variabile $_composer_autoload_path viene definita da Composer 2.2 e successivi nei proxy degli eseguibili: è il modo affidabile per trovare l'autoloader quando il pacchetto è installato in un progetto altrui o globalmente.
Colori, terminale e output
Su questo fronte Symfony Console fa quasi tutto da sé. ConsoleOutput scrive i risultati su standard output e fornisce, tramite getErrorOutput, un secondo canale collegato a standard error. Il supporto ai colori viene rilevato automaticamente: i codici ANSI vengono emessi solo se il flusso è un terminale, e il componente rispetta la variabile NO_COLOR. L'utente può inoltre forzare il comportamento con le opzioni globali --ansi e --no-ansi, e regolare la verbosità con -q e -v, -vv, -vvv.
Per scrivere diagnostica che compare solo in modalità verbosa basta passare il livello come secondo argomento:
$output->writeln(sprintf('File dati: %s', $path), OutputInterface::VERBOSITY_VERBOSE);
Segnali
Per operazioni lunghe, Symfony Console permette di reagire ai segnali implementando SignalableCommandInterface. La funzionalità richiede l'estensione pcntl, disponibile solo sui sistemi Unix.
#[AsCommand(name: 'import', description: 'Importa attività da un file esterno')]
final class ImportCommand extends Command implements SignalableCommandInterface
{
private bool $interrupted = false;
public function getSubscribedSignals(): array
{
return [\SIGINT, \SIGTERM];
}
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
// Restituendo false il processo non termina: concludiamo il lavoro in corso.
$this->interrupted = true;
return false;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
foreach ($this->readBatches() as $batch) {
if ($this->interrupted) {
$output->getErrorOutput()->writeln('tasks: importazione interrotta');
return 130;
}
$this->importBatch($batch);
}
return Command::SUCCESS;
}
}
Test
Il componente include ApplicationTester e CommandTester, che eseguono i comandi in memoria catturando l'output. Poiché però abbiamo spostato la gestione dei codici di uscita in Kernel::run, per verificare il comportamento completo usiamo direttamente StringInput e BufferedOutput, con un piccolo output composito che separa standard output e standard error.
<?php
declare(strict_types=1);
namespace Tasks\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Tasks\Kernel;
use Tasks\Store;
final class CliTest extends TestCase
{
private string $path;
protected function setUp(): void
{
$this->path = sys_get_temp_dir() . '/tasks-test-' . bin2hex(random_bytes(6)) . '/tasks.json';
}
protected function tearDown(): void
{
@unlink($this->path);
@rmdir(dirname($this->path));
}
/** @return array{int, string, string} codice di uscita, stdout, stderr */
private function runCli(string $command): array
{
$stdout = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false);
$stderr = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false);
$output = new class ($stdout, $stderr) extends ConsoleOutput {
public function __construct(private BufferedOutput $out, BufferedOutput $err)
{
parent::__construct(self::VERBOSITY_NORMAL, false);
$this->setErrorOutput($err);
}
protected function doWrite(string $message, bool $newline): void
{
// Il messaggio è già formattato: lo scriviamo così com'è.
$this->out->write($message, $newline, OutputInterface::OUTPUT_RAW);
}
};
$app = Kernel::application(new Store($this->path));
$code = Kernel::run($app, new StringInput($command), $output);
return [$code, $stdout->fetch(), $stderr->fetch()];
}
public function testAddAndListJson(): void
{
[$code] = $this->runCli('add Scrivere i test --priority=high');
self::assertSame(0, $code);
[$code, $out] = $this->runCli('list --format=json');
self::assertSame(0, $code);
$tasks = json_decode($out, true, flags: JSON_THROW_ON_ERROR);
self::assertCount(1, $tasks);
self::assertSame('Scrivere i test', $tasks[0]['title']);
self::assertSame('high', $tasks[0]['priority']);
}
public function testCompletedTasksAreHiddenByDefault(): void
{
$this->runCli('add Prima');
$this->runCli('add Seconda');
$this->runCli('done 1');
[, $out] = $this->runCli('list --format=json');
self::assertSame([2], array_column(json_decode($out, true), 'id'));
[, $out] = $this->runCli('list --all --format=json');
self::assertSame([1, 2], array_column(json_decode($out, true), 'id'));
}
/** @return iterable<string, array{string, int}> */
public static function exitCodes(): iterable
{
yield 'nessun comando' => ['', 2];
yield 'comando sconosciuto' => ['fly', 2];
yield 'titolo mancante' => ['add', 2];
yield 'priorità non valida' => ['add x --priority=urgent', 2];
yield 'opzione sconosciuta' => ['list --colour', 2];
yield 'id non numerico' => ['done 3abc', 2];
yield 'id inesistente' => ['done 42', 1];
yield 'aiuto' => ['list --help', 0];
}
#[DataProvider('exitCodes')]
public function testExitCodes(string $command, int $expected): void
{
[$code] = $this->runCli($command);
self::assertSame($expected, $code);
}
public function testErrorsGoToStderr(): void
{
[$code, $out, $err] = $this->runCli('rm 7');
self::assertSame(1, $code);
self::assertSame('', $out);
self::assertStringContainsString('attività non trovata', $err);
}
}
vendor/bin/phpunit tests
Distribuzione come PHAR
Chi usa Composer può installare lo strumento globalmente con composer global require example/tasks, ma per una distribuzione più ampia il formato naturale è il PHAR: un unico archivio eseguibile che contiene codice e dipendenze, esattamente come composer.phar o phpunit.phar. Lo strumento di riferimento per costruirlo è Box, configurato con un file box.json:
{
"main": "bin/tasks",
"output": "build/tasks.phar",
"directories": ["src"],
"compression": "GZ",
"check-requirements": true
}
composer install --no-dev --optimize-autoloader
box compile
./build/tasks.phar --version
sudo install -m 0755 build/tasks.phar /usr/local/bin/tasks
Il PHAR richiede comunque un interprete PHP sulla macchina di destinazione. Quando non è possibile garantirlo, progetti come static-php-cli permettono di combinare l'archivio con un interprete compilato staticamente, ottenendo un vero eseguibile autonomo.
Conclusione
Symfony Console ci ha dato gratuitamente molto di ciò che in altri linguaggi abbiamo dovuto scrivere a mano: aiuto generato, completamento della shell, rilevamento del terminale, supporto a NO_COLOR, gestione della verbosità e tabelle allineate anche in presenza di colori. Il nostro lavoro si è concentrato su ciò che il componente non può decidere al nostro posto: la distinzione tra errori di utilizzo e di esecuzione con i relativi codici di uscita, la protezione dell'output da markup indesiderato, la scrittura atomica dei dati e un'architettura che permette di testare l'applicazione esattamente come la usa l'utente finale.