Comandes personalitzades

Com crear comandes Artisan personalitzades: arguments, opcions, interacció amb l'usuari, barres de progrés, sortida formatada i programació.

Crear una comanda#

Laravel permet crear comandes Artisan personalitzades per automatitzar qualsevol tasca repetitiva de la teva aplicació: netejar dades antigues, generar informes, importar fitxers, sincronitzar amb serveis externs o qualsevol operació que vulguis executar des del terminal. Aquestes comandes tenen accés complet a tot el framework, incloent-hi Eloquent, events, cues, notificacions i qualsevol servei registrat al contenidor. Això les converteix en una eina extremadament potent per a l'administració i el manteniment de l'aplicació.

Per crear una nova comanda, utilitza el generador Artisan:

php artisan make:command SendWeeklyReport

Això crea un fitxer a app/Console/Commands/SendWeeklyReport.php amb l'estructura bàsica ja preparada. El generador crea una classe que estén Illuminate\Console\Command i inclou les propietats i mètodes essencials que tota comanda necessita:

namespace App\Console\Commands;
 
use Illuminate\Console\Command;
 
class SendWeeklyReport extends Command
{
    /**
     * El nom i la signatura de la comanda.
     */
    protected $signature = 'app:send-weekly-report';
 
    /**
     * La descripció de la comanda.
     */
    protected $description = 'Command description';
 
    /**
     * Executar la comanda.
     */
    public function handle(): int
    {
        //
 
        return Command::SUCCESS;
    }
}

Anatomia de la classe#

Cada comanda personalitzada té tres elements fonamentals que defineixen el seu comportament. Entendre cadascun és essencial per construir comandes robustes i ben estructurades.

La propietat $signature defineix el nom amb què invocaràs la comanda des del terminal, juntament amb els arguments i opcions que accepta. Per convenció, el nom utilitza dos punts com a separador de namespaces (per exemple, report:weekly o users:cleanup). El generador assigna un nom per defecte amb el prefix app:, però és recomanable canviar-lo per un nom descriptiu i organitzat per categoria:

// Convencions habituals de noms
protected $signature = 'report:weekly';     // Categoria: report
protected $signature = 'users:cleanup';     // Categoria: users
protected $signature = 'import:products';   // Categoria: import

La propietat $description és el text que apareix al llistat de comandes quan executes php artisan list o php artisan help. Ha de ser breu i descriptiu, explicant clarament què fa la comanda:

protected $description = 'Envia l\'informe setmanal per email als administradors';

El mètode handle() conté la lògica principal de la comanda. Laravel resol automàticament les dependències injectades al mètode gràcies al contenidor de serveis, cosa que significa que pots tipar qualsevol servei i Laravel l'instanciarà per tu:

use App\Services\ReportGenerator;
use App\Services\Mailer;
 
public function handle(ReportGenerator $generator, Mailer $mailer): int
{
    $report = $generator->weekly();
    $mailer->send($report);
 
    $this->info('Informe enviat correctament!');
 
    return Command::SUCCESS;
}

Valors de retorn#

El mètode handle() ha de retornar un codi de sortida enter que indica el resultat de l'execució. Laravel defineix tres constants per a aquest propòsit, que segueixen les convencions estàndard de Unix. El codi de sortida és important perquè altres processos (scripts de desplegament, CI/CD, programació de tasques) l'utilitzen per determinar si la comanda ha funcionat correctament:

public function handle(): int
{
    try {
        // Lògica de la comanda...
 
        return Command::SUCCESS;   // 0 - Tot correcte
    } catch (ValidationException $e) {
        $this->error('Dades invàlides: ' . $e->getMessage());
 
        return Command::INVALID;   // 2 - Entrada invàlida
    } catch (\Exception $e) {
        $this->error('Error inesperat: ' . $e->getMessage());
 
        return Command::FAILURE;   // 1 - Error general
    }
}

Command::SUCCESS (codi 0) indica que la comanda s'ha executat correctament. Command::FAILURE (codi 1) indica un error general durant l'execució. Command::INVALID (codi 2) indica que les dades d'entrada no eren vàlides. Retornar el codi correcte permet que pipelines de CI/CD i scripts automatitzats reaccionin adequadament als resultats de les comandes.

Sintaxi de la signatura: arguments i opcions#

La signatura de la comanda defineix no només el nom, sinó també tots els arguments i opcions que la comanda accepta. Laravel utilitza una sintaxi declarativa entre claus {} que és concisa i expressiva. Entendre bé aquesta sintaxi és fonamental perquè determina com els usuaris interactuaran amb la teva comanda.

Arguments#

Els arguments són valors posicionals que l'usuari passa directament després del nom de la comanda, sense cap prefix. L'ordre en què es defineixen és l'ordre en què s'han de proporcionar.

Un argument requerit es defineix simplement amb el seu nom entre claus. Si l'usuari no el proporciona, Laravel mostrarà un error:

// Argument requerit
protected $signature = 'users:show {user}';
 
// Ús: php artisan users:show 42

Un argument opcional s'indica amb un signe d'interrogació. Si l'usuari no el proporciona, el seu valor serà null:

// Argument opcional
protected $signature = 'users:show {user?}';
 
// Ús: php artisan users:show       -> user = null
// Ús: php artisan users:show 42    -> user = "42"

Pots assignar un valor per defecte a un argument amb el signe igual. Si l'usuari no el proporciona, s'utilitza el valor indicat:

// Argument amb valor per defecte
protected $signature = 'mail:send {destinatari=admin}';
 
// Ús: php artisan mail:send          -> destinatari = "admin"
// Ús: php artisan mail:send joan     -> destinatari = "joan"

Un argument d'array accepta múltiples valors. L'asterisc indica que l'argument pot rebre un o més valors separats per espais:

// Argument d'array (múltiples valors)
protected $signature = 'mail:send {destinatari*}';
 
// Ús: php artisan mail:send joan maria pere
// -> destinatari = ["joan", "maria", "pere"]

Opcions#

Les opcions són paràmetres amb prefix -- que poden aparèixer en qualsevol ordre. A diferència dels arguments, les opcions no són posicionals i sempre comencen amb dos guions.

Una opció booleana (flag) actua com un interruptor: si l'usuari la inclou, el seu valor és true; si no la inclou, és false:

// Opció booleana (flag)
protected $signature = 'mail:send {destinatari} {--force}';
 
// Ús: php artisan mail:send joan           -> force = false
// Ús: php artisan mail:send joan --force   -> force = true

Una opció amb valor s'indica amb un signe igual al final del nom. Això indica a Laravel que l'opció espera un valor. Si l'usuari no la proporciona, el valor serà null:

// Opció amb valor
protected $signature = 'mail:send {destinatari} {--email=}';
 
// Ús: php artisan mail:send joan                          -> email = null
// Ús: php artisan mail:send joan --email=joan@exemple.com -> email = "joan@exemple.com"

Pots definir un valor per defecte per a una opció. Si l'usuari no la proporciona, s'utilitza aquest valor:

// Opció amb valor per defecte
protected $signature = 'mail:send {destinatari} {--email=admin@exemple.com}';
 
// Ús: php artisan mail:send joan                          -> email = "admin@exemple.com"
// Ús: php artisan mail:send joan --email=joan@exemple.com -> email = "joan@exemple.com"

Una opció d'array permet passar múltiples valors per a la mateixa opció. L'asterisc després del signe igual indica que l'opció es pot repetir:

// Opció d'array (múltiples valors)
protected $signature = 'mail:send {--id=*}';
 
// Ús: php artisan mail:send --id=1 --id=2 --id=3
// -> id = ["1", "2", "3"]

Les opcions també poden tenir dreceres (shortcuts) d'un sol caràcter, definides amb una barra vertical abans del nom complet:

// Opcions amb drecera
protected $signature = 'mail:send
    {destinatari}
    {--F|force}
    {--Q|queue=}';
 
// Ús: php artisan mail:send joan -F -Q emails
// Equivalent a: php artisan mail:send joan --force --queue=emails

Descripcions d'arguments i opcions#

Per millorar l'ajuda de la comanda (visible amb php artisan help), pots afegir descripcions a cada argument i opció utilitzant dos punts com a separador. Això és especialment important en comandes complexes amb múltiples paràmetres:

protected $signature = 'users:export
    {format : El format de sortida (csv, json, xlsx)}
    {--S|since= : Data d\'inici del filtre (YYYY-MM-DD)}
    {--role=* : Rols a incloure (pot repetir-se)}
    {--active : Només usuaris actius}';

Quan algú executi php artisan help users:export, veurà una descripció clara de cada paràmetre amb el text que has proporcionat.

Accedir als arguments i opcions#

Dins del mètode handle(), pots accedir als valors proporcionats per l'usuari mitjançant diversos mètodes. El mètode argument() retorna el valor d'un argument específic, mentre que arguments() retorna tots els arguments com un array. De la mateixa manera, option() retorna el valor d'una opció i options() retorna totes les opcions:

protected $signature = 'users:export
    {format}
    {--since=}
    {--role=*}
    {--active}';
 
public function handle(): int
{
    // Accedir a un argument individual
    $format = $this->argument('format'); // "csv", "json", etc.
 
    // Accedir a tots els arguments
    $allArguments = $this->arguments();
    // ['command' => 'users:export', 'format' => 'csv']
 
    // Accedir a una opció individual
    $since = $this->option('since');  // "2024-01-01" o null
    $roles = $this->option('role');   // ["admin", "editor"] o []
    $active = $this->option('active'); // true o false
 
    // Accedir a totes les opcions
    $allOptions = $this->options();
    // ['since' => '2024-01-01', 'role' => [...], 'active' => true, ...]
 
    $this->info("Exportant usuaris en format {$format}...");
 
    return Command::SUCCESS;
}

Fixa't que arguments() sempre inclou una entrada command amb el nom de la comanda, i que options() inclou opcions globals com --help, --quiet i --verbose a més de les teves opcions personalitzades.

Interacció amb l'usuari#

Les comandes Artisan no sempre poden obtenir tota la informació que necessiten a través d'arguments i opcions. De vegades cal preguntar a l'usuari durant l'execució: confirmar una acció destructiva, demanar una contrasenya, o oferir opcions dinàmiques basades en dades de la base de dades. Laravel proporciona dos conjunts d'eines per a això: els prompts clàssics integrats a la classe Command i el paquet Laravel Prompts, que ofereix una experiència d'interacció moderna i visualment atractiva.

Prompts clàssics#

Els prompts clàssics són mètodes disponibles directament a qualsevol comanda. Funcionen a tots els terminals i són la manera més senzilla d'interactuar amb l'usuari.

El mètode ask() fa una pregunta oberta i retorna la resposta com a string. El mètode secret() fa el mateix però amaga l'entrada de l'usuari (ideal per a contrasenyes). El mètode confirm() fa una pregunta de sí/no i retorna un booleà:

public function handle(): int
{
    // Pregunta oberta
    $name = $this->ask('Com es diu l\'usuari?');
 
    // Pregunta amb valor per defecte
    $email = $this->ask('Quin és el seu email?', 'admin@exemple.com');
 
    // Entrada secreta (la contrasenya no es mostra)
    $password = $this->secret('Defineix una contrasenya');
 
    // Confirmació (sí/no)
    if ($this->confirm('Vols crear l\'usuari ara?')) {
        User::create([
            'name' => $name,
            'email' => $email,
            'password' => Hash::make($password),
        ]);
 
        $this->info('Usuari creat correctament!');
    }
 
    return Command::SUCCESS;
}

El mètode choice() presenta una llista d'opcions i retorna la selecció de l'usuari. Pots especificar un índex per defecte (el que es selecciona si l'usuari prem Enter sense escriure res) i permetre selecció múltiple passant true com a quart paràmetre:

public function handle(): int
{
    // Selecció simple
    $role = $this->choice(
        'Quin rol ha de tenir?',
        ['admin', 'editor', 'viewer'],
        0 // Índex per defecte: 'admin'
    );
 
    // Selecció múltiple
    $permissions = $this->choice(
        'Quins permisos vols assignar?',
        ['create', 'read', 'update', 'delete'],
        null,   // Sense valor per defecte
        null,   // Nombre màxim d'intents
        true    // Permetre selecció múltiple
    );
 
    $this->info("Rol: {$role}");
    $this->info('Permisos: ' . implode(', ', $permissions));
 
    return Command::SUCCESS;
}

El mètode anticipate() funciona com ask() però amb autocompletat. L'usuari pot escriure lliurement, però rep suggeriments basats en l'array o la closure que proporcionis. Això és molt útil quan tens un conjunt gran de valors possibles:

public function handle(): int
{
    // Autocompletat amb array estàtic
    $city = $this->anticipate(
        'De quina ciutat ets?',
        ['Andorra la Vella', 'Escaldes-Engordany', 'Encamp', 'La Massana', 'Ordino', 'Canillo', 'Sant Julià de Lòria']
    );
 
    // Autocompletat dinàmic amb closure
    $user = $this->anticipate('Cerca un usuari', function (string $input) {
        return User::where('name', 'like', "%{$input}%")
            ->pluck('name')
            ->toArray();
    });
 
    return Command::SUCCESS;
}

Laravel Prompts#

A partir de Laravel 10, el paquet Laravel Prompts ofereix una experiència d'interacció molt més rica i moderna, amb elements visuals atractius, validació integrada, navegació amb teclat i un disseny que recorda les interfícies d'aplicacions de terminal modernes. Laravel Prompts és la manera recomanada de construir comandes interactives, i quan la comanda s'executa en un terminal que no suporta les funcionalitats avançades (com un pipeline CI/CD), les funcions de Prompts es degraden automàticament als prompts clàssics.

Per utilitzar Laravel Prompts, importa les funcions des del namespace Laravel\Prompts:

use function Laravel\Prompts\text;
use function Laravel\Prompts\password;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\select;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\search;
use function Laravel\Prompts\multisearch;
use function Laravel\Prompts\suggest;
use function Laravel\Prompts\spin;
use function Laravel\Prompts\progress;
use function Laravel\Prompts\table;
use function Laravel\Prompts\note;
use function Laravel\Prompts\alert;
use function Laravel\Prompts\pause;

La funció text() és l'equivalent millorat de ask(). Suporta placeholder, valor per defecte, validació integrada i un hint (text d'ajuda) que apareix sota el camp d'entrada:

$name = text(
    label: 'Com es diu l\'usuari?',
    placeholder: 'Joan Garcia',
    default: '',
    required: true,
    validate: fn (string $value) => match (true) {
        strlen($value) < 2 => 'El nom ha de tenir almenys 2 caràcters.',
        strlen($value) > 100 => 'El nom no pot superar els 100 caràcters.',
        default => null,
    },
    hint: 'El nom complet de l\'usuari.',
);

La funció password() amaga l'entrada i suporta validació. La funció confirm() mostra una pregunta de sí/no amb estil modern:

$password = password(
    label: 'Defineix una contrasenya',
    required: true,
    validate: fn (string $value) => match (true) {
        strlen($value) < 8 => 'La contrasenya ha de tenir almenys 8 caràcters.',
        default => null,
    },
    hint: 'Mínim 8 caràcters.',
);
 
$confirmed = confirm(
    label: 'Vols activar l\'autenticació de dos factors?',
    default: true,
    yes: 'Sí, activa-la',
    no: 'No, ara no',
    hint: 'Podràs canviar-ho més endavant.',
);

La funció select() presenta una llista d'opcions navegable amb les fletxes del teclat. El valor retornat és la clau de l'array associatiu (o el valor si passes un array sequencial):

$role = select(
    label: 'Quin rol ha de tenir l\'usuari?',
    options: [
        'admin' => 'Administrador',
        'editor' => 'Editor',
        'viewer' => 'Lector',
    ],
    default: 'viewer',
    hint: 'L\'administrador té accés total al sistema.',
);
// $role serà 'admin', 'editor' o 'viewer'

La funció multiselect() permet seleccionar múltiples opcions. L'usuari navega amb les fletxes i marca/desmarca amb la barra d'espai:

$permissions = multiselect(
    label: 'Quins permisos vols assignar?',
    options: [
        'create' => 'Crear contingut',
        'read' => 'Llegir contingut',
        'update' => 'Editar contingut',
        'delete' => 'Eliminar contingut',
    ],
    default: ['read'],
    required: true,
    hint: 'Utilitza la barra d\'espai per seleccionar.',
);

Les funcions search() i multisearch() permeten cercar entre opcions de manera dinàmica. Són ideals quan tens centenars o milers d'opcions possibles (per exemple, seleccionar un usuari d'una base de dades gran). L'usuari escriu i les opcions es filtren en temps real:

$userId = search(
    label: 'Cerca l\'usuari',
    options: function (string $value) {
        if (strlen($value) < 2) return [];
 
        return User::where('name', 'like', "%{$value}%")
            ->pluck('name', 'id')
            ->toArray();
    },
    placeholder: 'Escriu almenys 2 caràcters...',
    hint: 'Cerca per nom.',
);
 
$tagIds = multisearch(
    label: 'Selecciona les etiquetes',
    options: function (string $value) {
        return Tag::where('name', 'like', "%{$value}%")
            ->pluck('name', 'id')
            ->toArray();
    },
    placeholder: 'Cerca etiquetes...',
);

La funció suggest() combina un camp de text lliure amb suggeriments d'autocompletat. A diferència de search(), l'usuari pot escriure qualsevol valor, no només els que apareixen a la llista:

$city = suggest(
    label: 'De quina parròquia ets?',
    options: [
        'Andorra la Vella',
        'Escaldes-Engordany',
        'Encamp',
        'La Massana',
        'Ordino',
        'Canillo',
        'Sant Julià de Lòria',
    ],
    placeholder: 'Escriu el nom de la parròquia...',
    hint: 'Pots escriure un valor diferent als suggerits.',
);

La funció spin() mostra un spinner animat mentre s'executa una operació llarga, indicant visualment a l'usuari que la comanda està treballant:

$result = spin(
    message: 'Generant l\'informe...',
    callback: function () {
        // Operació que triga uns segons
        return ReportGenerator::generate();
    },
);

La funció progress() mostra una barra de progrés moderna mentre itera sobre una col·lecció. Rep l'etiqueta, els elements a processar i una closure per a cada element:

$users = User::all();
 
$results = progress(
    label: 'Processant usuaris...',
    steps: $users,
    callback: function (User $user) {
        // Processar cada usuari
        $user->sendNotification();
 
        return $user->name;
    },
    hint: 'Això pot trigar uns minuts.',
);

La funció table() mostra una taula formatada al terminal. Les funcions note() i alert() mostren missatges destacats, i pause() atura l'execució fins que l'usuari prem Enter:

// Taula
table(
    headers: ['ID', 'Nom', 'Email'],
    rows: User::all(['id', 'name', 'email'])->toArray(),
);
 
// Nota informativa
note('L\'operació s\'ha completat correctament.');
 
// Alerta important
alert('Atenció: aquesta acció és irreversible!');
 
// Pausa
pause('Prem Enter per continuar...');

Quan utilitzar cada tipus#

Els prompts clàssics ($this->ask(), $this->confirm(), etc.) són adequats per a comandes senzilles o quan necessites compatibilitat total amb qualsevol terminal. Laravel Prompts és la opció recomanada per a comandes que requereixen una interacció rica amb l'usuari, validació d'entrada en temps real, o una experiència visual moderna. Si el terminal no suporta les funcionalitats avançades de Prompts, les funcions es degraden automàticament als prompts clàssics, de manera que no hi ha risc en utilitzar-los sempre.

Sortida formatada#

Comunicar informació clara i ben organitzada a l'usuari és una part important de qualsevol comanda. Laravel proporciona diversos mètodes per formatar la sortida amb colors, estils i estructures que fan la informació fàcil de llegir al terminal.

Missatges amb colors#

Els mètodes bàsics de sortida apliquen colors diferents per distingir visualment el tipus de missatge. Cada color té un significat convencional que els usuaris de terminal reconeixen immediatament:

public function handle(): int
{
    $this->info('Operació completada correctament.');  // Verd
    $this->error('No s\'ha pogut connectar a la BD.');  // Fons vermell
    $this->warn('El fitxer ja existeix, es sobreescriurà.'); // Groc
    $this->line('Text sense format especial.');          // Sense color
    $this->comment('Iniciant el procés...');             // Groc (comentari)
    $this->question('Vols continuar?');                  // Fons negre, text cyan
 
    // Línies en blanc
    $this->newLine();    // Una línia buida
    $this->newLine(3);   // Tres línies buides
 
    return Command::SUCCESS;
}

Per a sortida més detallada quan l'usuari utilitza el flag --verbose, pots condicionar els missatges al nivell de verbositat. Això permet que les comandes siguin concises per defecte però proporcionin informació detallada de depuració quan cal:

public function handle(): int
{
    $this->info('Processant dades...');
 
    // Només visible amb -v
    $this->line('Connectant a la base de dades...', verbosity: 'v');
 
    // Només visible amb -vv
    $this->line('Query: SELECT * FROM users WHERE active = 1', verbosity: 'vv');
 
    // Només visible amb -vvv (màxima verbositat)
    $this->line('Resposta raw del servidor: {...}', verbosity: 'vvv');
 
    return Command::SUCCESS;
}

Taules#

El mètode table() mostra dades tabulars amb capçaleres, alineació automàtica i marges. És ideal per mostrar llistats de registres, resultats de consultes o informació comparativa:

public function handle(): int
{
    $users = User::where('active', true)->get();
 
    $this->table(
        ['ID', 'Nom', 'Email', 'Rol', 'Registrat'],
        $users->map(fn (User $user) => [
            $user->id,
            $user->name,
            $user->email,
            $user->role,
            $user->created_at->format('d/m/Y'),
        ])->toArray()
    );
 
    return Command::SUCCESS;
}

La sortida al terminal es veurà així:

+----+-------------+---------------------+---------+------------+
| ID | Nom         | Email               | Rol     | Registrat  |
+----+-------------+---------------------+---------+------------+
| 1  | Joan Garcia | joan@exemple.com    | admin   | 15/01/2024 |
| 2  | Maria Puig  | maria@exemple.com   | editor  | 22/03/2024 |
| 3  | Pere Font   | pere@exemple.com    | viewer  | 08/07/2024 |
+----+-------------+---------------------+---------+------------+

Barres de progrés#

Les barres de progrés són essencials per a comandes que processen grans volums de dades. Sense una indicació visual de progrés, l'usuari no sap si la comanda funciona, quant falta per acabar, o si s'ha bloquejat. Laravel ofereix dues maneres de mostrar barres de progrés.

El mètode manual utilitza createProgressBar() i et dona control total sobre el comportament de la barra. Pots avançar-la, personalitzar-la i aturar-la quan vulguis:

public function handle(): int
{
    $users = User::where('active', false)
        ->where('last_login_at', '<', now()->subYear())
        ->get();
 
    $this->info("Netejant {$users->count()} comptes inactius...");
 
    $bar = $this->output->createProgressBar($users->count());
    $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%');
    $bar->start();
 
    $deleted = 0;
 
    foreach ($users as $user) {
        if ($user->canBeDeleted()) {
            $user->delete();
            $deleted++;
        }
 
        $bar->advance();
    }
 
    $bar->finish();
    $this->newLine(2);
 
    $this->info("S'han eliminat {$deleted} comptes de {$users->count()} processats.");
 
    return Command::SUCCESS;
}

El mètode withProgressBar() és una alternativa més concisa que encapsula tot el cicle de vida de la barra de progrés en una sola crida. Rep una col·lecció iterable i una closure que s'executa per a cada element:

public function handle(): int
{
    $orders = Order::where('status', 'pending')->get();
 
    $this->info("Processant {$orders->count()} comandes pendents...");
 
    $this->withProgressBar($orders, function (Order $order) {
        $order->process();
    });
 
    $this->newLine(2);
    $this->info('Totes les comandes processades!');
 
    return Command::SUCCESS;
}

El mètode withProgressBar() és ideal per a la majoria de casos. Utilitza createProgressBar() quan necessitis més control: per exemple, si vols mostrar informació addicional durant el processament, saltar elements condicionalment sense avançar la barra, o personalitzar el format de la barra.

Cridar comandes des del codi#

No sempre necessites executar comandes des del terminal. Pot ser útil cridar una comanda Artisan des d'un controlador, un servei, un job o una altra comanda. Laravel proporciona diverses maneres de fer-ho, cadascuna amb el seu cas d'ús.

Des de controladors i serveis#

La facade Artisan::call() executa una comanda de manera síncrona i retorna el codi de sortida. Pots passar arguments i opcions com un array associatiu:

use Illuminate\Support\Facades\Artisan;
 
// Cridar una comanda amb arguments i opcions
$exitCode = Artisan::call('report:weekly', [
    'user' => 42,
    '--email' => 'joan@exemple.com',
    '--force' => true,
]);
 
if ($exitCode === 0) {
    // La comanda s'ha executat correctament
}
 
// Obtenir la sortida de la comanda
Artisan::call('users:export', ['format' => 'json']);
$output = Artisan::output();

El mètode Artisan::call() executa la comanda immediatament dins del procés actual, cosa que significa que bloquejarà l'execució fins que la comanda acabi. Per a comandes que triguen molt, considera utilitzar Artisan::queue() per encuar-les com un job:

use Illuminate\Support\Facades\Artisan;
 
// Encuar la comanda com un job (es processarà en segon pla)
Artisan::queue('report:weekly', [
    '--email' => 'admin@exemple.com',
]);
 
// Especificar connexió i cua
Artisan::queue('report:weekly', [
    '--email' => 'admin@exemple.com',
])->onConnection('redis')->onQueue('reports');

Amb Artisan::queue(), la comanda es processa com qualsevol altre job: de manera asíncrona, per un worker, amb suport per a reintentos i monitorització. Això és ideal per a tasques llargues disparades des d'una interfície web.

Des d'una altra comanda#

Dins d'una comanda, pots cridar altres comandes amb $this->call(). Això és útil per compondre comandes complexes a partir de comandes més petites i reutilitzables:

public function handle(): int
{
    $this->info('Iniciant el procés de desplegament...');
 
    // Executar migracions
    $this->call('migrate', ['--force' => true]);
 
    // Netejar caches
    $this->call('cache:clear');
    $this->call('config:cache');
    $this->call('route:cache');
    $this->call('view:cache');
 
    $this->info('Desplegament completat!');
 
    return Command::SUCCESS;
}

El mètode $this->call() mostra la sortida de la comanda cridada al terminal. Si vols suprimir la sortida (per exemple, perquè la comanda principal ja mostra el seu propi resum), utilitza $this->callSilently():

public function handle(): int
{
    // Executar sense mostrar la sortida de la subcomanda
    $this->callSilently('cache:clear');
    $this->callSilently('config:cache');
 
    $this->info('Caches actualitzades correctament.');
 
    return Command::SUCCESS;
}

Registrar comandes#

Laravel necessita saber que les teves comandes existeixen per poder-les executar. El mecanisme de registre ha evolucionat al llarg de les versions de Laravel, i és important entendre les opcions disponibles.

Descobriment automàtic#

Per defecte, Laravel descobreix automàticament totes les comandes situades al directori app/Console/Commands/. Qualsevol classe que estengui Illuminate\Console\Command dins d'aquest directori es registra automàticament. No cal cap configuració addicional. Si crees una comanda amb php artisan make:command, ja està disponible:

app/
└── Console/
    └── Commands/
        ├── SendWeeklyReport.php    ← Descoberta automàticament
        ├── CleanupInactiveUsers.php ← Descoberta automàticament
        └── ImportProducts.php       ← Descoberta automàticament

Comandes closure a routes/console.php#

Per a comandes senzilles que no necessiten una classe dedicada, pots definir-les directament al fitxer routes/console.php com a closures. Això és ideal per a comandes ràpides d'una sola funció:

// routes/console.php
 
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
 
Artisan::command('db:cleanup-logs {--days=30}', function () {
    $days = $this->option('days');
 
    $deleted = DB::table('logs')
        ->where('created_at', '<', now()->subDays($days))
        ->delete();
 
    $this->info("S'han eliminat {$deleted} registres de log de fa més de {$days} dies.");
})->purpose('Elimina els registres de log antics.');

El mètode purpose() estableix la descripció de la comanda que apareix al llistat php artisan list. Les comandes closure tenen accés als mateixos mètodes d'interacció i sortida que les comandes basades en classes ($this->ask(), $this->info(), $this->table(), etc.).

Pots tipar dependències a la closure i Laravel les resoldrà automàticament:

use App\Services\CacheManager;
 
Artisan::command('cache:warm', function (CacheManager $cache) {
    $cache->warmAll();
    $this->info('Cache precarregada correctament.');
})->purpose('Precarrega la cache de l\'aplicació.');

Canvis a Laravel 11#

Laravel 11 va simplificar l'estructura del projecte eliminant el fitxer app/Console/Kernel.php. En versions anteriors, calia registrar manualment les comandes al Kernel o configurar el descobriment automàtic. A Laravel 11 i posteriors, el descobriment automàtic està activat per defecte i no cal cap configuració. Les comandes closure i la programació de tasques es defineixen a routes/console.php:

// routes/console.php (Laravel 11+)
 
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
 
// Comanda closure
Artisan::command('inspire', function () {
    $this->comment(\Inspiring::quote());
})->purpose('Mostra una cita inspiradora.');
 
// Programació de tasques (abans es feia al Kernel)
Schedule::command('report:weekly')->weeklyOn(1, '8:00');
Schedule::command('db:cleanup-logs --days=90')->daily();

Si necessites registrar comandes des d'un paquet o un directori diferent d'app/Console/Commands, pots fer-ho al fitxer bootstrap/app.php:

// bootstrap/app.php
 
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
 
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
    )
    ->withCommands([
        __DIR__.'/../app/Console/Commands',
        __DIR__.'/../packages/my-package/src/Commands',
    ])
    ->withMiddleware(function (Middleware $middleware) {
        //
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })
    ->create();

Gestió de senyals#

Les comandes que executen processos llargs (importacions massives, workers personalitzats, processament de fitxers) necessiten poder aturar-se de manera neta quan reben un senyal del sistema operatiu. Sense gestió de senyals, un Ctrl+C (SIGINT) o un kill (SIGTERM) interromp la comanda abruptament, cosa que pot deixar dades en un estat inconsistent: transaccions a mig fer, fitxers temporals sense esborrar, o processos fills orfes.

El mètode trap() permet interceptar senyals del sistema operatiu i executar una closure de neteja abans de sortir. Això garanteix una aturada elegant (graceful shutdown):

use App\Models\ImportBatch;
 
public function handle(): int
{
    $shouldStop = false;
 
    $this->trap([SIGINT, SIGTERM], function (int $signal) use (&$shouldStop) {
        $this->warn('Senyal rebut. Aturant després del registre actual...');
        $shouldStop = true;
    });
 
    $records = ImportBatch::pending()->cursor();
 
    foreach ($records as $record) {
        if ($shouldStop) {
            $this->info('Aturat de manera neta. Registres pendents: ' .
                ImportBatch::pending()->count());
            return Command::SUCCESS;
        }
 
        $record->process();
        $this->line("Processat: {$record->id}");
    }
 
    $this->info('Tots els registres processats!');
 
    return Command::SUCCESS;
}

En aquest exemple, quan l'usuari prem Ctrl+C, la comanda no s'atura immediatament. En canvi, activa el flag $shouldStop, i la comanda acaba de processar el registre actual abans de sortir de manera neta. Això evita deixar un registre a mig processar.

Pots interceptar múltiples senyals amb comportaments diferents. Per exemple, SIGINT (Ctrl+C) pot aturar el processament de manera elegant, mentre que SIGUSR1 pot forçar una recàrrega de configuració:

$this->trap(SIGINT, function () use (&$shouldStop) {
    $shouldStop = true;
});
 
$this->trap(SIGUSR1, function () use (&$config) {
    $config = config()->reload();
    $this->info('Configuració recarregada.');
});

Comandes aïllades#

En entorns de producció amb múltiples servidors o programació de tasques, una mateixa comanda pot iniciar-se diverses vegades simultàniament. Per exemple, si un cron executa php artisan report:daily cada hora i el procés triga més d'una hora, la següent execució començarà mentre l'anterior encara funciona. Això pot causar problemes greus: dades duplicades, conflictes de recursos, o resultats inconsistents.

La interfície Isolatable evita exactament aquest problema. Quan una comanda implementa Isolatable, Laravel adquireix un lock atòmic abans d'executar-la. Si el lock ja existeix (perquè una altra instància de la mateixa comanda s'està executant), la nova instància es nega a executar-se:

namespace App\Console\Commands;
 
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;
 
class GenerateDailyReport extends Command implements Isolatable
{
    protected $signature = 'report:daily';
 
    protected $description = 'Genera l\'informe diari de vendes';
 
    public function handle(): int
    {
        $this->info('Generant informe diari...');
 
        // Procés llarg que no ha de córrer simultàniament
        $report = Report::generateDaily();
        $report->send();
 
        $this->info('Informe generat i enviat!');
 
        return Command::SUCCESS;
    }
}

Quan algú intenta executar la comanda mentre ja s'està executant, Laravel mostra un missatge indicant que la comanda ja està en curs i retorna un codi de sortida que indica que no s'ha executat. No cal fer res més: la interfície Isolatable és l'únic requisit.

El lock s'allibera automàticament quan la comanda acaba. Per defecte, el lock utilitza el driver de cache configurat a l'aplicació. Si fas servir múltiples servidors, assegura't que el driver de cache sigui compartit (com Redis o Memcached) perquè el lock funcioni entre servidors.

Pots personalitzar la clau del lock sobreescrivint el mètode isolatableId(). Això és útil quan vols que la comanda sigui aïllada per algun paràmetre específic (per exemple, permetre una execució per tipus d'informe):

class GenerateReport extends Command implements Isolatable
{
    protected $signature = 'report:generate {type}';
 
    // Aïllar per tipus d'informe
    public function isolatableId(): string
    {
        return $this->argument('type');
    }
 
    // Temps d'expiració del lock (en segons)
    public function isolationLockExpiresAt(): DateTimeInterface|DateInterval
    {
        return now()->addMinutes(30);
    }
}

Amb aquesta configuració, php artisan report:generate daily i php artisan report:generate weekly poden executar-se simultàniament (perquè tenen claus de lock diferents), però dues instàncies de php artisan report:generate daily no poden córrer al mateix temps.

També pots forçar l'aïllament manualment amb el flag --isolated en qualsevol comanda que implementi la interfície:

php artisan report:daily --isolated
 
# Especificar el codi de sortida si la comanda ja s'executa
php artisan report:daily --isolated=12

Testing de comandes#

Testejar comandes Artisan és important per garantir que la lògica d'automatització funciona correctament. Laravel proporciona un conjunt complet d'eines per verificar els codis de sortida, la sortida per terminal, la interacció amb l'usuari i el comportament general de les comandes.

El mètode artisan() dins dels tests executa una comanda i retorna un objecte que permet encadenar assertions. El mètode assertExitCode() verifica el codi de sortida de la comanda:

namespace Tests\Feature;
 
use Tests\TestCase;
 
class ReportCommandTest extends TestCase
{
    public function test_weekly_report_succeeds(): void
    {
        $this->artisan('report:weekly')
            ->assertExitCode(0);
    }
 
    public function test_report_with_invalid_email_fails(): void
    {
        $this->artisan('report:weekly', ['--email' => 'no-es-un-email'])
            ->assertExitCode(2); // Command::INVALID
    }
 
    public function test_report_returns_success(): void
    {
        $this->artisan('report:weekly')
            ->assertSuccessful(); // Equivalent a assertExitCode(0)
    }
 
    public function test_report_returns_failure(): void
    {
        // Simular un error de connexió
        $this->artisan('report:weekly')
            ->assertFailed(); // Codi de sortida != 0
    }
}

Testejar la sortida#

Pots verificar que la comanda mostra els missatges esperats amb expectsOutput() i doesntExpectOutput():

public function test_cleanup_shows_results(): void
{
    // Preparar dades
    User::factory()->count(5)->create([
        'active' => false,
        'last_login_at' => now()->subYears(2),
    ]);
 
    $this->artisan('users:cleanup')
        ->expectsOutput('5 comptes inactius eliminats.')
        ->assertExitCode(0);
}
 
public function test_cleanup_with_no_users(): void
{
    $this->artisan('users:cleanup')
        ->expectsOutput('No hi ha comptes inactius per eliminar.')
        ->doesntExpectOutput('eliminats')
        ->assertExitCode(0);
}

Testejar la interacció amb l'usuari#

Per a comandes que fan preguntes a l'usuari, pots simular les respostes amb expectsQuestion(), expectsConfirmation() i expectsChoice(). Aquestes assertions funcionen en ordre: cada crida correspon a la pregunta corresponent de la comanda:

public function test_create_user_interactively(): void
{
    $this->artisan('users:create')
        ->expectsQuestion('Com es diu l\'usuari?', 'Joan Garcia')
        ->expectsQuestion('Quin és el seu email?', 'joan@exemple.com')
        ->expectsQuestion('Defineix una contrasenya', 'secret123')
        ->expectsConfirmation('Vols crear l\'usuari ara?', 'yes')
        ->expectsOutput('Usuari creat correctament!')
        ->assertExitCode(0);
 
    $this->assertDatabaseHas('users', [
        'name' => 'Joan Garcia',
        'email' => 'joan@exemple.com',
    ]);
}
 
public function test_user_cancels_creation(): void
{
    $this->artisan('users:create')
        ->expectsQuestion('Com es diu l\'usuari?', 'Joan Garcia')
        ->expectsQuestion('Quin és el seu email?', 'joan@exemple.com')
        ->expectsQuestion('Defineix una contrasenya', 'secret123')
        ->expectsConfirmation('Vols crear l\'usuari ara?', 'no')
        ->assertExitCode(0);
 
    $this->assertDatabaseMissing('users', [
        'email' => 'joan@exemple.com',
    ]);
}

Per testejar comandes amb choice(), utilitza expectsChoice(). El tercer paràmetre és la resposta i el quart (opcional) són les opcions que la comanda hauria de presentar:

public function test_export_with_role_choice(): void
{
    $this->artisan('users:export')
        ->expectsChoice(
            'Quin rol vols exportar?',
            'admin',
            ['admin', 'editor', 'viewer']
        )
        ->assertExitCode(0);
}

Testejar taules#

El mètode expectsTable() verifica que la comanda mostra una taula amb les capçaleres i files esperades:

public function test_list_users_shows_table(): void
{
    User::factory()->create(['name' => 'Joan', 'email' => 'joan@exemple.com']);
    User::factory()->create(['name' => 'Maria', 'email' => 'maria@exemple.com']);
 
    $this->artisan('users:list')
        ->expectsTable(
            ['Nom', 'Email'],
            [
                ['Joan', 'joan@exemple.com'],
                ['Maria', 'maria@exemple.com'],
            ]
        )
        ->assertExitCode(0);
}

Test complet amb mocking#

Per a comandes que depenen de serveis externs, combina el testing de comandes amb mocking per aïllar la lògica de la comanda:

use App\Services\ReportGenerator;
use Mockery;
 
public function test_weekly_report_sends_email(): void
{
    // Crear un mock del servei
    $generator = Mockery::mock(ReportGenerator::class);
    $generator->shouldReceive('weekly')
        ->once()
        ->andReturn(new Report(['title' => 'Informe setmanal']));
 
    $this->app->instance(ReportGenerator::class, $generator);
 
    Mail::fake();
 
    $this->artisan('report:weekly', ['--email' => 'admin@exemple.com'])
        ->expectsOutput('Informe enviat correctament!')
        ->assertExitCode(0);
 
    Mail::assertSent(WeeklyReportMail::class, function ($mail) {
        return $mail->hasTo('admin@exemple.com');
    });
}

Exemple pràctic: comanda d'importació de productes#

Per veure com es combinen totes les funcionalitats anteriors en una comanda real, construirem una comanda d'importació de productes que llegeix un fitxer CSV, valida les dades, mostra el progrés i genera un informe final. Aquesta comanda demostra la signatura amb arguments i opcions, la interacció amb l'usuari, la sortida formatada, les barres de progrés, la gestió de senyals i l'aïllament.

namespace App\Console\Commands;
 
use App\Models\Product;
use App\Models\Category;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\LazyCollection;
 
class ImportProducts extends Command implements Isolatable
{
    protected $signature = 'products:import
        {file : El camí al fitxer CSV a importar}
        {--delimiter=, : El delimitador del CSV}
        {--skip-header : Saltar la primera fila (capçalera)}
        {--dry-run : Simular la importació sense guardar res}
        {--chunk=100 : Nombre de registres per lot}
        {--category= : Categoria per defecte si no s\'especifica al CSV}';
 
    protected $description = 'Importa productes des d\'un fitxer CSV a la base de dades';
 
    private bool $shouldStop = false;
    private int $imported = 0;
    private int $skipped = 0;
    private int $errors = 0;
    private array $errorDetails = [];
 
    public function handle(): int
    {
        // Gestió de senyals per aturada elegant
        $this->trap([SIGINT, SIGTERM], function () {
            $this->warn('Senyal d\'aturada rebut. Finalitzant el lot actual...');
            $this->shouldStop = true;
        });
 
        $filePath = $this->argument('file');
 
        // Verificar que el fitxer existeix
        if (! file_exists($filePath)) {
            $this->error("El fitxer '{$filePath}' no existeix.");
            return Command::INVALID;
        }
 
        // Mostrar resum de l'operació
        $isDryRun = $this->option('dry-run');
        $lineCount = $this->countLines($filePath);
 
        $this->info('=== Importació de productes ===');
        $this->newLine();
        $this->line("Fitxer: {$filePath}");
        $this->line("Registres: {$lineCount}");
        $this->line("Delimitador: '{$this->option('delimiter')}'");
        $this->line('Mode: ' . ($isDryRun ? 'Simulació (dry-run)' : 'Importació real'));
 
        if ($this->option('category')) {
            $category = Category::where('slug', $this->option('category'))->first();
 
            if (! $category) {
                $this->error("La categoria '{$this->option('category')}' no existeix.");
                return Command::INVALID;
            }
 
            $this->line("Categoria per defecte: {$category->name}");
        }
 
        $this->newLine();
 
        // Demanar confirmació si no és dry-run
        if (! $isDryRun && ! $this->confirm("Vols importar {$lineCount} productes a la base de dades?")) {
            $this->comment('Importació cancel·lada.');
            return Command::SUCCESS;
        }
 
        // Processar el fitxer
        $this->info('Processant el fitxer...');
        $this->newLine();
 
        $chunkSize = (int) $this->option('chunk');
 
        $rows = LazyCollection::make(function () use ($filePath) {
            $handle = fopen($filePath, 'r');
 
            while (($line = fgetcsv($handle, 0, $this->option('delimiter'))) !== false) {
                yield $line;
            }
 
            fclose($handle);
        });
 
        if ($this->option('skip-header')) {
            $rows = $rows->skip(1);
            $lineCount--;
        }
 
        $bar = $this->output->createProgressBar($lineCount);
        $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | Importats: %imported% | Errors: %errors%');
        $bar->setMessage('0', 'imported');
        $bar->setMessage('0', 'errors');
        $bar->start();
 
        $rows->chunk($chunkSize)->each(function (LazyCollection $chunk) use ($bar, $isDryRun) {
            if ($this->shouldStop) {
                return false;
            }
 
            DB::transaction(function () use ($chunk, $bar, $isDryRun) {
                foreach ($chunk as $row) {
                    if ($this->shouldStop) {
                        break;
                    }
 
                    $result = $this->processRow($row, $isDryRun);
 
                    $bar->setMessage((string) $this->imported, 'imported');
                    $bar->setMessage((string) $this->errors, 'errors');
                    $bar->advance();
                }
            });
        });
 
        $bar->finish();
        $this->newLine(2);
 
        // Mostrar l'informe final
        $this->showReport($isDryRun);
 
        return $this->errors > 0 ? Command::FAILURE : Command::SUCCESS;
    }
 
    private function processRow(array $row, bool $isDryRun): bool
    {
        // Validar que la fila té el nombre correcte de columnes
        if (count($row) < 4) {
            $this->errors++;
            $this->errorDetails[] = [
                'fila' => $this->imported + $this->skipped + $this->errors,
                'error' => 'Nombre insuficient de columnes (' . count($row) . ')',
                'dades' => implode(', ', $row),
            ];
            return false;
        }
 
        [$sku, $name, $price, $categorySlug] = $row;
 
        // Validar les dades
        $validator = Validator::make(
            ['sku' => $sku, 'name' => $name, 'price' => $price],
            [
                'sku' => 'required|string|max:50',
                'name' => 'required|string|max:255',
                'price' => 'required|numeric|min:0',
            ]
        );
 
        if ($validator->fails()) {
            $this->errors++;
            $this->errorDetails[] = [
                'fila' => $this->imported + $this->skipped + $this->errors,
                'error' => implode('; ', $validator->errors()->all()),
                'dades' => "{$sku}, {$name}, {$price}",
            ];
            return false;
        }
 
        // Verificar si ja existeix
        if (Product::where('sku', $sku)->exists()) {
            $this->skipped++;
            return false;
        }
 
        // Crear el producte (o simular-ho)
        if (! $isDryRun) {
            $categoryId = Category::where('slug', $categorySlug ?: $this->option('category'))
                ->value('id');
 
            Product::create([
                'sku' => $sku,
                'name' => trim($name),
                'price' => (float) $price,
                'category_id' => $categoryId,
            ]);
        }
 
        $this->imported++;
        return true;
    }
 
    private function showReport(bool $isDryRun): void
    {
        $this->info('=== Informe d\'importació ===');
        $this->newLine();
 
        $this->table(
            ['Mètrica', 'Valor'],
            [
                ['Mode', $isDryRun ? 'Simulació' : 'Importació real'],
                ['Importats', $this->imported],
                ['Omesos (duplicats)', $this->skipped],
                ['Errors', $this->errors],
                ['Total processats', $this->imported + $this->skipped + $this->errors],
            ]
        );
 
        if ($this->shouldStop) {
            $this->warn('La importació s\'ha aturat manualment. Pot haver-hi registres sense processar.');
        }
 
        if (count($this->errorDetails) > 0) {
            $this->newLine();
            $this->warn('Detall dels errors:');
            $this->table(
                ['Fila', 'Error', 'Dades'],
                array_map(fn ($e) => [$e['fila'], $e['error'], $e['dades']], $this->errorDetails)
            );
        }
 
        if (! $isDryRun && $this->imported > 0) {
            $this->newLine();
            $this->info("S'han importat {$this->imported} productes correctament.");
        }
    }
 
    private function countLines(string $filePath): int
    {
        $count = 0;
        $handle = fopen($filePath, 'r');
 
        while (fgets($handle) !== false) {
            $count++;
        }
 
        fclose($handle);
 
        return $count;
    }
 
    public function isolatableId(): string
    {
        return $this->argument('file');
    }
}

Aquesta comanda es podria executar de les maneres següents:

# Importació bàsica
php artisan products:import storage/imports/productes.csv --skip-header
 
# Simulació prèvia (no toca la BD)
php artisan products:import storage/imports/productes.csv --skip-header --dry-run
 
# Amb delimitador personalitzat i categoria per defecte
php artisan products:import storage/imports/productes.csv \
    --delimiter=";" \
    --skip-header \
    --category=electronics \
    --chunk=500
 
# Des d'un controlador
Artisan::call('products:import', [
    'file' => storage_path('imports/productes.csv'),
    '--skip-header' => true,
    '--dry-run' => true,
]);

El test per a aquesta comanda podria ser:

namespace Tests\Feature;
 
use App\Models\Category;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
 
class ImportProductsTest extends TestCase
{
    use RefreshDatabase;
 
    private string $csvPath;
 
    protected function setUp(): void
    {
        parent::setUp();
 
        Category::factory()->create(['slug' => 'electronics', 'name' => 'Electrònica']);
 
        $this->csvPath = storage_path('test-products.csv');
        file_put_contents($this->csvPath, implode("\n", [
            'SKU,Nom,Preu,Categoria',
            'PRD-001,Portàtil Pro,999.99,electronics',
            'PRD-002,Teclat Mecànic,79.50,electronics',
            'PRD-003,Monitor 27",349.00,electronics',
        ]));
    }
 
    protected function tearDown(): void
    {
        @unlink($this->csvPath);
        parent::tearDown();
    }
 
    public function test_imports_products_from_csv(): void
    {
        $this->artisan('products:import', [
            'file' => $this->csvPath,
            '--skip-header' => true,
        ])
            ->expectsConfirmation('Vols importar 3 productes a la base de dades?', 'yes')
            ->assertExitCode(0);
 
        $this->assertDatabaseCount('products', 3);
        $this->assertDatabaseHas('products', ['sku' => 'PRD-001', 'price' => 999.99]);
    }
 
    public function test_dry_run_does_not_create_products(): void
    {
        $this->artisan('products:import', [
            'file' => $this->csvPath,
            '--skip-header' => true,
            '--dry-run' => true,
        ])
            ->assertExitCode(0);
 
        $this->assertDatabaseCount('products', 0);
    }
 
    public function test_fails_with_nonexistent_file(): void
    {
        $this->artisan('products:import', [
            'file' => '/tmp/no-existeix.csv',
        ])
            ->expectsOutput("El fitxer '/tmp/no-existeix.csv' no existeix.")
            ->assertExitCode(2); // Command::INVALID
    }
 
    public function test_skips_duplicate_products(): void
    {
        Product::factory()->create(['sku' => 'PRD-001']);
 
        $this->artisan('products:import', [
            'file' => $this->csvPath,
            '--skip-header' => true,
        ])
            ->expectsConfirmation('Vols importar 3 productes a la base de dades?', 'yes')
            ->assertExitCode(0);
 
        $this->assertDatabaseCount('products', 3); // 1 existent + 2 nous
    }
}

L'exemple demostra com una comanda ben construïda combina una signatura clara amb arguments i opcions, validació de dades, feedback visual amb barres de progrés, gestió d'errors amb informes detallats, aturada elegant amb gestió de senyals, i aïllament per evitar execucions simultànies. Seguir aquests patrons permet construir comandes robustes que funcionen de manera fiable tant en desenvolupament com en producció.