planeta jupiter planeta tierra

Haz tu vida más fácil con los Comandos de Symfony

· 7 min de lectura · Symfony

Ilustración de una pila de libros junto a una vela encendida
nave extraterrestre
Ilustración de una pila de libros junto a una vela encendida

Todos tenemos ese script.php suelto que hay que ejecutar «a mano cada lunes», y que solo sabe lanzar quien lo escribió. Convertirlo en un comando de Symfony cuesta diez minutos y a cambio te da ayuda, argumentos, colores, barra de progreso, códigos de salida y tests. Se acabó el script de la carpeta rara.

Cuándo merece la pena un comando

Cualquier cosa que no venga de una petición HTTP: importar un CSV, recalcular estadísticas de madrugada, limpiar datos antiguos, mandar el resumen semanal, migrar información entre dos sistemas, o darle a alguien de negocio un botón que puedas explicar por teléfono.

Un comando es una clase normal de tu aplicación: tiene inyección de dependencias, acceso a los servicios, a la base de datos y a la configuración. No es un script aparte, es tu aplicación ejecutándose desde la terminal.

1. El comando mínimo

Si tienes MakerBundle, php bin/console make:command te lo genera. A mano son veinte líneas:

namespace App\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 Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:users:cleanup',
    description: 'Borra las cuentas que nunca se activaron',
)]
final class CleanupUsersCommand extends Command
{
    public function __construct(
        private UserRepository $users,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $deleted = $this->users->deleteNeverActivated();

        $io->success(sprintf('%d cuentas borradas.', $deleted));

        return Command::SUCCESS;
    }
}

El atributo #[AsCommand] lo registra: no hay que tocar servicios ni configuración. Ya aparece en php bin/console con su descripción, agrupado por el prefijo app:.

php bin/console app:users:cleanup

Un consejo de convivencia: usa siempre un prefijo propio (app:, o el nombre del dominio) para que tus comandos no se mezclen con los de Symfony ni con los de los bundles.

2. SymfonyStyle: que se lea bien

Podrías escribir con $output->writeln(), pero SymfonyStyle te da un aspecto consistente con el resto de la consola de Symfony y sin pelearte con etiquetas de color:

$io->title('Limpieza de usuarios');
$io->section('Buscando cuentas sin activar');

$io->text('Se revisan las cuentas de más de 30 días.');
$io->listing(['ana@example.com', 'luis@example.com']);

$io->table(
    ['Email', 'Alta', 'Estado'],
    [
        ['ana@example.com', '2026-01-04', 'sin activar'],
        ['luis@example.com', '2026-02-11', 'sin activar'],
    ]
);

$io->note('Nada se ha borrado todavía.');
$io->warning('Esta operación no se puede deshacer.');
$io->success('Listo.');
$io->error('Algo ha salido mal.');

Cuatro métodos y el comando ya parece profesional. Y si mañana lo lanza otra persona, entiende lo que está pasando sin preguntarte.

3. Argumentos y opciones

Un argumento es un valor posicional (obligatorio o no); una opción lleva guiones y suele modificar el comportamiento. La diferencia práctica: si el usuario tiene que pensar qué va primero, hazlo opción.

use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;

protected function configure(): void
{
    $this
        ->addArgument('email', InputArgument::OPTIONAL, 'Limitar a un email concreto')
        ->addOption('days', null, InputOption::VALUE_REQUIRED, 'Antigüedad en días', 30)
        ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Enseña lo que haría, sin borrar');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $io = new SymfonyStyle($input, $output);

    $email  = $input->getArgument('email');
    $days   = (int) $input->getOption('days');
    $dryRun = $input->getOption('dry-run');

    // ...
}
php bin/console app:users:cleanup --days=90 --dry-run
php bin/console app:users:cleanup ana@example.com

Ese --dry-run gástalo siempre en comandos destructivos. Es la diferencia entre probar en producción con tranquilidad y probar en producción rezando.

Desde Symfony 7.3 hay una forma más corta: un comando invocable con __invoke() y los parámetros declarados con atributos.

use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;

public function __invoke(
    SymfonyStyle $io,
    #[Argument(description: 'Limitar a un email concreto')]
    ?string $email = null,
    #[Option(description: 'Antigüedad en días')]
    int $days = 30,
    #[Option(description: 'Enseña lo que haría, sin borrar')]
    bool $dryRun = false,
): int {
    // ...

    return Command::SUCCESS;
}

4. Preguntar al usuario

if (!$io->confirm('¿Seguro que quieres borrar 412 cuentas?', false)) {
    $io->text('Cancelado.');

    return Command::SUCCESS;
}

$motivo = $io->choice('Motivo', ['inactividad', 'petición del usuario', 'spam'], 'inactividad');
$nombre = $io->ask('Nombre del informe', 'limpieza-'.date('Y-m-d'));

Ahora bien: cuando ese mismo comando lo lance un cron, no habrá nadie para responder. Por eso todas las preguntas deben tener un valor por defecto razonable y el cron debe llamarse con --no-interaction (o -n), que hace que se acepten esos valores sin preguntar.

5. Barra de progreso para lo que tarda

$io->progressStart(count($users));

foreach ($users as $user) {
    $this->process($user);
    $io->progressAdvance();
}

$io->progressFinish();

Si el proceso son horas, escupe además una línea cada X elementos: una barra que no se mueve y un log vacío son indistinguibles de un comando colgado.

Y aprovecha los niveles de verbosidad en lugar de comentar y descomentar dump():

if ($output->isVerbose()) {          // -v
    $io->text('Procesando '.$user->getEmail());
}

if ($output->isVeryVerbose()) {      // -vv
    $io->text('SQL: '.$query->getSQL());
}

6. Códigos de salida: esto no es decorativo

Lo que devuelve execute() es el código de salida del proceso, y es lo único que miran el cron, Jenkins, GitHub Actions o tu && del despliegue.

return Command::SUCCESS;  // 0, todo bien
return Command::FAILURE;  // 1, ha fallado
return Command::INVALID;  // 2, lo has llamado mal

Devolver SUCCESS pase lo que pase es la razón número uno de que un fallo pase semanas sin que nadie se entere.

7. Que no se solape consigo mismo

El comando de las 3:00 tarda hoy más de una hora y a las 4:00 arranca otro encima del primero. LockableTrait lo resuelve en dos líneas:

use Symfony\Component\Console\Command\LockableTrait;

final class CleanupUsersCommand extends Command
{
    use LockableTrait;

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        if (!$this->lock()) {
            $output->writeln('Ya hay otra ejecución en marcha.');

            return Command::SUCCESS;
        }

        // ... trabajo ...

        $this->release();

        return Command::SUCCESS;
    }
}

Necesita symfony/lock instalado. Es de esas cosas que no echas de menos hasta que un lunes tienes los correos duplicados.

8. En cron, como es debido

0 3 * * * cd /var/www/app && php bin/console app:users:cleanup \
    --env=prod --no-interaction \
    >> var/log/cleanup.log 2>&1

Cuatro detalles que evitan sustos: entrar en el directorio del proyecto (el cron no comparte tu cwd), fijar el entorno, desactivar la interactividad y guardar la salida incluyendo los errores, que es lo que hace 2>&1. Si no rediriges nada, el día que falle no habrá ni rastro.

9. Probarlo sin ejecutarlo a mano

Un comando se testea como cualquier otra cosa, con CommandTester:

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;

final class CleanupUsersCommandTest extends KernelTestCase
{
    public function testBorraLasCuentasSinActivar(): void
    {
        $application = new Application(self::bootKernel());
        $command = $application->find('app:users:cleanup');

        $tester = new CommandTester($command);
        $tester->execute(['--days' => 90, '--dry-run' => true]);

        $tester->assertCommandIsSuccessful();
        $this->assertStringContainsString('cuentas borradas', $tester->getDisplay());
    }
}

Si el comando pregunta, $tester->setInputs(['yes']) le da las respuestas antes de ejecutar.

Lo que se rompe si no lo sabes

  • La memoria se acaba en los bucles largos. Doctrine guarda en memoria todo lo que has cargado: procesa por lotes y llama a clear() cada X elementos, o usa toIterable().
  • Nada de echo. Escribe siempre por $output/$io: es lo que respeta la verbosidad, el --quiet y la redirección a fichero.
  • El cron no es tu terminal. Otro usuario, otro PATH, otro directorio y sin variables de tu .bashrc. Rutas absolutas siempre.
  • Un comando interactivo en cron se queda colgado. Con --no-interaction y valores por defecto, no.
  • Devuelve el código correcto. Captura las excepciones que sepas manejar y devuelve FAILURE en el resto: es la única señal que ve quien lo lanza.
  • Cuidado con --env=prod. Ese comando toca la base de datos de producción con los servicios de producción. Ten un --dry-run y úsalo.

Y ya está

Una clase con #[AsCommand], SymfonyStyle para que se entienda, un par de opciones, el código de salida correcto y un bloqueo si va a cron. Ese script suelto pasa a ser parte de la aplicación: se despliega con ella, se prueba con ella y lo puede lanzar cualquiera del equipo.

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.