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 usatoIterable(). - Nada de
echo. Escribe siempre por$output/$io: es lo que respeta la verbosidad, el--quiety 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-interactiony valores por defecto, no. - Devuelve el código correcto. Captura las excepciones que sepas manejar
y devuelve
FAILUREen 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-runy ú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.