Módulo 14: Comandos de consola

Objetivos del módulo

  • Entender el componente Console y cómo funciona bin/console
  • Crear comandos con make:command y el atributo #[AsCommand]
  • Escribir comandos invocables con __invoke() y los atributos #[Argument] y #[Option], y compararlos con el estilo clásico configure()/execute()
  • Dominar argumentos, opciones y la salida con SymfonyStyle (tablas, barras de progreso, preguntas…)
  • Usar correctamente los códigos de retorno y la verbosidad
  • Inyectar servicios en comandos y crear comandos útiles para el blog
  • Programar tareas periódicas con el componente Scheduler y compararlo con el cron clásico
  • Conocer los comandos de mantenimiento integrados: cache:*, debug:* y lint:*

1. El componente Console

📘 Concepto: Un comando de consola es un punto de entrada a tu aplicación que no pasa por HTTP: lo ejecutas desde la terminal. Es la “puerta de servicio” del blog: por ahí entran las tareas de mantenimiento, las importaciones, los procesos programados y las operaciones de administración que no merecen una pantalla.

Llevas todo el curso usando comandos: make:entity, doctrine:migrations:migrate, debug:router… Todos se ejecutan a través de bin/console, que arranca el kernel de Symfony igual que public/index.php, pero en lugar de atender una petición HTTP ejecuta un comando.

# lista de todos los comandos disponibles (es el comando por defecto)
php bin/console list

# solo los de un "espacio de nombres"
php bin/console list doctrine

# ayuda de un comando: descripción, argumentos y opciones
php bin/console help doctrine:migrations:migrate
php bin/console doctrine:migrations:migrate --help

# no hace falta escribir el nombre completo si no es ambiguo
php bin/console d:m:m

Anatomía de una llamada

php bin/console app:comments:purge-spam   30      --force   --env=prod  -v
                └──────── nombre ───────┘ └argumento┘ └opción┘ └opciones globales┘
Parte Qué es Ejemplo
Nombre Identifica el comando; se agrupa con : app:comments:purge-spam
Argumento Valor posicional, sin guiones 30
Opción Modificador con nombre, con -- (o - si es corta) --force, --days=30, -f
Opciones globales Disponibles en todos los comandos --env, --no-debug, -v, -q, -n

💡 Consejo: Usa siempre el prefijo app: para tus comandos. Así no chocan con los de Symfony o los de bundles de terceros, y php bin/console list app te muestra solo los tuyos.


2. Crear un comando: make:command y #[AsCommand]

php bin/console make:command app:hello

MakerBundle genera src/Command/HelloCommand.php:

<?php

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:hello',
    description: 'Add a short description for your command',
)]
class HelloCommand
{
    public function __invoke(
        SymfonyStyle $io,
    ): int {

        $io->success('You have a new command! Now make it your own! Pass --help to see your options.');

        return Command::SUCCESS;
    }
}

Fíjate en tres cosas:

  1. La clase no extiende nada: es un servicio normal con un método __invoke().
  2. El atributo #[AsCommand] hace que Symfony la registre como comando gracias a la autoconfiguración. No hay que tocar services.yaml.
  3. __invoke() recibe lo que necesita (aquí, SymfonyStyle para escribir en la terminal) y devuelve un entero: el código de salida.
php bin/console app:hello
 [OK] You have a new command! Now make it your own! Pass --help to see your options.

Opciones de #[AsCommand]

#[AsCommand(
    name: 'app:hello',
    description: 'Saluda a un usuario del blog',                 // aparece en "list"
    help: 'Este comando saluda. Úsalo para comprobar que la consola funciona.', // aparece en --help
    usages: ['Ana', 'Ana --yell'],                                // ejemplos de uso en --help
    aliases: ['app:hi'],                                          // nombres alternativos
    hidden: false,                                                // true lo oculta de "list"
)]

📘 Concepto: Los comandos se cargan de forma perezosa (lazy): Symfony lee el nombre del atributo sin instanciar la clase, y solo crea el objeto (con todas sus dependencias) cuando ejecutas ese comando. Por eso tener cien comandos no ralentiza bin/console.


3. Comandos invocables frente al estilo clásico

Desde Symfony 7.3, los argumentos y opciones se declaran directamente en los parámetros de __invoke() con los atributos #[Argument] y #[Option]. Es el estilo recomendado y el que genera MakerBundle.

Estilo invocable (moderno)

<?php
// src/Command/HelloCommand.php
declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:hello', description: 'Saluda a un usuario del blog')]
final class HelloCommand
{
    public function __invoke(
        SymfonyStyle $io,

        #[Argument(description: 'Nombre de la persona a saludar')]
        string $name,

        #[Option(description: 'Número de saludos', shortcut: 't')]
        int $times = 1,

        #[Option(description: 'Saludar en mayúsculas')]
        bool $yell = false,
    ): int {
        $greeting = sprintf('¡Hola, %s!', $name);

        for ($i = 0; $i < $times; $i++) {
            $io->writeln($yell ? mb_strtoupper($greeting) : $greeting);
        }

        return Command::SUCCESS;
    }
}
php bin/console app:hello Ana --times=2 --yell
¡HOLA, ANA!
¡HOLA, ANA!

Estilo clásico (configure() + execute())

Lo encontrarás en muchísimos proyectos, tutoriales y bundles. El mismo comando:

<?php
declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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 Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:hello', description: 'Saluda a un usuario del blog')]
final class HelloCommand extends Command
{
    protected function configure(): void
    {
        $this
            ->addArgument('name', InputArgument::REQUIRED, 'Nombre de la persona a saludar')
            ->addOption('times', 't', InputOption::VALUE_REQUIRED, 'Número de saludos', 1)
            ->addOption('yell', null, InputOption::VALUE_NONE, 'Saludar en mayúsculas');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $name = $input->getArgument('name');
        $times = (int) $input->getOption('times');   // ¡las opciones llegan como string!
        $greeting = sprintf('¡Hola, %s!', $name);

        for ($i = 0; $i < $times; $i++) {
            $io->writeln($input->getOption('yell') ? mb_strtoupper($greeting) : $greeting);
        }

        return Command::SUCCESS;
    }
}
  Invocable (__invoke + atributos) Clásico (configure + execute)
Disponible desde Symfony 7.3 Siempre
Extiende Command No (opcional) Sí
Definición de entrada Parámetros tipados con #[Argument]/#[Option] addArgument()/addOption()
Tipos Inferidos y convertidos (int, bool, enums…) Todo llega como string/bool/array; conviertes tú
Lectura de valores Variables directas $input->getArgument('x')
Hooks initialize()/interact() Atributos #[Ask]/#[Interact] (7.4+) o extender Command Métodos sobrescritos
Recomendado para código nuevo Sí Para mantener código existente

💡 Consejo: Si necesitas los métodos del ciclo de vida (initialize(), interact()), puedes extender Command y seguir usando __invoke() con atributos. Son compatibles.

Cómo se infiere el modo de cada argumento y opción

Declaración Resultado
#[Argument] string $name Argumento obligatorio
#[Argument] string $name = '' o ?string $name = null Argumento opcional
#[Argument] array $names = [] Argumento múltiple (recoge el resto de valores)
#[Option] bool $force = false Flag sin valor: --force
#[Option] bool $color = true Flag negable: --color / --no-color
#[Option] int $days = 30 Opción con valor obligatorio: --days=7
#[Option] ?string $author = null Opción con valor, opcional
#[Option] array $tag = [] Opción repetible: --tag=php --tag=symfony
#[Option] string\|bool $output = false Valor opcional: --output o --output=fichero.csv

Reglas importantes:

  • Las opciones siempre necesitan valor por defecto (por definición, son opcionales).
  • El nombre se deduce del parámetro en kebab-case: $dryRun → --dry-run. Puedes cambiarlo con name:.
  • También puedes tipar con un enum respaldado (BackedEnum, desde 7.4): Symfony convierte el texto al caso del enum y ofrece autocompletado.
enum ExportFormat: string
{
    case Json = 'json';
    case Csv = 'csv';
}

public function __invoke(#[Option] ExportFormat $format = ExportFormat::Json): int
// php bin/console app:articles:export --format=csv

Preguntar lo que falta: #[Ask] (7.4+)

En lugar de escribir un método interact(), puedes pedir de forma interactiva un argumento obligatorio que no se haya pasado:

use Symfony\Component\Console\Attribute\Ask;

public function __invoke(
    #[Argument, Ask('Email del administrador')]
    string $email,

    #[Argument, Ask('Contraseña', hidden: true)]   // no se ve al escribir
    string $password,
): int

Si ejecutas php bin/console app:user:create-admin sin argumentos, el comando te los pregunta; si los pasas, no pregunta nada. Con --no-interaction (-n) las preguntas se omiten.

📘 Concepto: Cuando un comando tiene muchos argumentos y opciones, puedes agruparlos en un DTO con #[MapInput] (7.4+): una clase con propiedades públicas marcadas con #[Argument]/#[Option] que recibes como __invoke(#[MapInput] CreateUserInput $input). Es la misma idea que #[MapRequestPayload] en los controladores.

💡 Consejo: Symfony 8.1 permite además definir varios comandos en una misma clase poniendo #[AsCommand] en métodos públicos (por ejemplo UserCommands::create() y UserCommands::delete()), útil cuando comparten dependencias.


4. Salida con SymfonyStyle

SymfonyStyle es una capa sobre la salida que da a todos los comandos un aspecto coherente (el mismo que ves en los comandos de Symfony).

#[AsCommand(name: 'app:style-demo', description: 'Muestra los elementos de SymfonyStyle')]
final class StyleDemoCommand
{
    public function __invoke(SymfonyStyle $io): int
    {
        $io->title('Estadísticas del blog');
        $io->section('Artículos');

        $io->text(['Resumen de los últimos 30 días.', 'Datos de ejemplo.']);
        $io->listing(['12 publicados', '3 borradores', '2 programados']);

        $io->table(
            ['Categoría', 'Artículos', 'Comentarios'],
            [
                ['Symfony', 8, 54],
                ['Doctrine', 3, 12],
                ['Twig', 1, 2],
            ],
        );

        $io->definitionList(
            ['Autor más activo' => 'Ana'],
            ['Último artículo' => 'Novedades de Symfony 8.1'],
        );

        $io->note('Los datos se recalculan cada noche.');
        $io->warning('Hay 2 comentarios pendientes de moderar.');
        $io->success('Informe generado.');
        $io->error('Esto es un mensaje de error de ejemplo.');

        return Command::SUCCESS;
    }
}

Salida (aproximada):

Estadísticas del blog
=====================

Artículos
---------

 Resumen de los últimos 30 días.
 Datos de ejemplo.

 * 12 publicados
 * 3 borradores
 * 2 programados

 ----------- ----------- -------------
  Categoría   Artículos   Comentarios
 ----------- ----------- -------------
  Symfony     8           54
  Doctrine    3           12
  Twig        1           2
 ----------- ----------- -------------

 ! [NOTE] Los datos se recalculan cada noche.

 [WARNING] Hay 2 comentarios pendientes de moderar.

 [OK] Informe generado.

 [ERROR] Esto es un mensaje de error de ejemplo.

Preguntas al usuario

$name = $io->ask('¿Nombre de la categoría?', 'General');            // con valor por defecto

$slug = $io->ask('¿Slug?', null, function (?string $answer): string {
    if (null === $answer || !preg_match('/^[a-z0-9-]+$/', $answer)) {
        throw new \RuntimeException('Solo minúsculas, números y guiones.');
    }
    return $answer;                                                  // validador: repite la pregunta si falla
});

$password = $io->askHidden('Contraseña');                            // no se muestra al escribir

if (!$io->confirm('¿Seguro que quieres continuar?', false)) {        // por defecto "no"
    $io->comment('Operación cancelada.');
    return Command::SUCCESS;
}

$role = $io->choice('Rol del usuario', ['ROLE_USER', 'ROLE_AUTHOR', 'ROLE_ADMIN'], 'ROLE_AUTHOR');

Barras de progreso

$comments = $repository->findSpamOlderThan($limit);

// forma más cómoda: recorre un iterable mostrando el progreso
foreach ($io->progressIterate($comments) as $comment) {
    $this->em->remove($comment);
}

// forma manual, cuando no tienes un iterable o quieres más control
$io->progressStart(count($comments));
foreach ($comments as $comment) {
    // ...
    $io->progressAdvance();
}
$io->progressFinish();
 150/150 [============================] 100%

Resumen de métodos

Método Uso
title(), section() Encabezados
text(), listing(), comment() Texto normal, lista, comentario
table($cabeceras, $filas) Tabla
definitionList(), horizontalTable() Pares clave-valor
note(), caution(), info() Avisos informativos
success(), warning(), error() Bloques de resultado
ask(), askHidden(), confirm(), choice() Preguntas
progressStart(), progressAdvance(), progressFinish() Barra de progreso manual
progressIterate($iterable) Barra de progreso al recorrer
newLine($n) Líneas en blanco

⚠️ Importante: Las preguntas bloquean el comando esperando respuesta. En tareas automáticas (cron, Scheduler, CI) se ejecutan con --no-interaction: ask() devuelve el valor por defecto y confirm() también. Diseña tus comandos para que funcionen sin intervención humana pasando todo por argumentos y opciones.


5. Códigos de retorno

📘 Concepto: Todo proceso de terminal termina con un código de salida: 0 significa éxito y cualquier otro número, error. Es lo que leen cron, Supervisor, los pipelines de CI o un && en la shell para decidir si continuar.

Constante Valor Cuándo
Command::SUCCESS 0 Todo ha ido bien (aunque no hubiera nada que hacer)
Command::FAILURE 1 Ha ocurrido un error durante la ejecución
Command::INVALID 2 Uso incorrecto: argumentos u opciones no válidos
php bin/console app:articles:publish-scheduled && echo "Publicación correcta"
echo $?    # muestra el código de salida del último comando (en bash)
if ($days < 1) {
    $io->error('El número de días debe ser mayor que 0.');
    return Command::INVALID;
}

try {
    $count = $this->publisher->publishDue();
} catch (\Throwable $e) {
    $io->error('Error al publicar: '.$e->getMessage());
    return Command::FAILURE;
}

💡 Consejo: Si dejas escapar una excepción, Symfony la muestra formateada y devuelve un código distinto de 0. Captúrala solo cuando puedas dar un mensaje mejor o hacer limpieza.


6. Inyección de servicios en comandos

Un comando es un servicio más: recibe sus dependencias por el constructor con autowiring, exactamente igual que un controlador o cualquier servicio del módulo 09.

#[AsCommand(name: 'app:stats', description: 'Estadísticas rápidas del blog')]
final class StatsCommand
{
    public function __construct(
        private readonly ArticleRepository $articles,
        private readonly CommentRepository $comments,
        #[Autowire('%env(APP_NAME)%')]
        private readonly string $appName = 'Blog',
    ) {
    }

    public function __invoke(SymfonyStyle $io): int
    {
        $io->title($this->appName);
        $io->table(['Dato', 'Valor'], [
            ['Artículos publicados', $this->articles->countPublished()],
            ['Comentarios pendientes', $this->comments->countPending()],
        ]);

        return Command::SUCCESS;
    }
}

⚠️ Importante: No pongas lógica de negocio dentro del comando. El comando debe ser una capa fina: lee la entrada, llama a un servicio (ArticlePublisher, UserManager…) y muestra el resultado. Así puedes reutilizar esa lógica desde un controlador, un handler de Messenger o un test.


7. Comandos útiles para el blog

7.1. Publicar artículos programados

En el módulo 13 escribiste el servicio ArticlePublisher y su test. Ahora le damos una puerta de entrada en la consola. Primero, la consulta en el repositorio:

// src/Repository/ArticleRepository.php (fragmento)

/** @return list<Article> */
public function findDueForPublication(\DateTimeImmutable $now): array
{
    return $this->createQueryBuilder('a')
        ->andWhere('a.publishedAt IS NULL')
        ->andWhere('a.scheduledAt IS NOT NULL')
        ->andWhere('a.scheduledAt <= :now')
        ->setParameter('now', $now)
        ->orderBy('a.scheduledAt', 'ASC')
        ->getQuery()
        ->getResult();
}

El comando:

<?php
// src/Command/PublishScheduledArticlesCommand.php
declare(strict_types=1);

namespace App\Command;

use App\Repository\ArticleRepository;
use App\Service\ArticlePublisher;
use Psr\Clock\ClockInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:articles:publish-scheduled',
    description: 'Publica los artículos programados cuya fecha ya ha llegado',
)]
final class PublishScheduledArticlesCommand
{
    public function __construct(
        private readonly ArticlePublisher $publisher,
        private readonly ArticleRepository $articles,
        private readonly ClockInterface $clock,
    ) {
    }

    public function __invoke(
        SymfonyStyle $io,
        #[Option(description: 'Muestra qué se publicaría sin publicar nada')]
        bool $dryRun = false,
    ): int {
        if ($dryRun) {
            $io->note('Modo simulación: no se modificará la base de datos.');
            $due = $this->articles->findDueForPublication($this->clock->now());

            $io->table(
                ['ID', 'Título', 'Programado para'],
                array_map(fn ($a) => [$a->getId(), $a->getTitle(), $a->getScheduledAt()->format('d/m/Y H:i')], $due),
            );
            $io->success(sprintf('%d artículos se publicarían.', count($due)));

            return Command::SUCCESS;
        }

        $count = $this->publisher->publishDue();

        if (0 === $count) {
            $io->comment('No hay artículos pendientes de publicar.');
        } else {
            $io->success(sprintf('%d artículos publicados.', $count));
        }

        return Command::SUCCESS;
    }
}
php bin/console app:articles:publish-scheduled --dry-run
php bin/console app:articles:publish-scheduled
 [OK] 2 artículos publicados.

7.2. Limpiar comentarios marcados como spam

Supón que Comment tiene una propiedad status con el enum CommentStatus (Pending, Approved, Spam), que usa el panel de moderación.

<?php
// src/Command/PurgeSpamCommentsCommand.php
declare(strict_types=1);

namespace App\Command;

use App\Enum\CommentStatus;
use App\Repository\CommentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:comments:purge-spam',
    description: 'Borra los comentarios marcados como spam más antiguos que N días',
)]
final class PurgeSpamCommentsCommand
{
    private const BATCH_SIZE = 100;

    public function __construct(
        private readonly CommentRepository $comments,
        private readonly EntityManagerInterface $em,
        private readonly ClockInterface $clock,
    ) {
    }

    public function __invoke(
        SymfonyStyle $io,
        #[Option(description: 'Antigüedad mínima en días')]
        int $days = 30,
        #[Option(description: 'Borra sin pedir confirmación', shortcut: 'f')]
        bool $force = false,
    ): int {
        if ($days < 1) {
            $io->error('--days debe ser mayor que 0.');
            return Command::INVALID;
        }

        $limit = $this->clock->now()->modify(sprintf('-%d days', $days));
        $total = $this->comments->countByStatusOlderThan(CommentStatus::Spam, $limit);

        if (0 === $total) {
            $io->success('No hay comentarios spam que borrar.');
            return Command::SUCCESS;
        }

        if (!$force && !$io->confirm(sprintf('Se van a borrar %d comentarios. ¿Continuar?', $total), false)) {
            $io->comment('Cancelado.');
            return Command::SUCCESS;
        }

        $deleted = 0;
        $query = $this->comments->queryByStatusOlderThan(CommentStatus::Spam, $limit);

        // toIterable() no carga todos los comentarios en memoria de golpe
        foreach ($io->progressIterate($query->toIterable(), $total) as $comment) {
            $this->em->remove($comment);
            if (0 === ++$deleted % self::BATCH_SIZE) {
                $this->em->flush();
                $this->em->clear();      // libera memoria
            }
        }
        $this->em->flush();

        $io->success(sprintf('%d comentarios spam eliminados.', $deleted));

        return Command::SUCCESS;
    }
}

💡 Consejo: Para borrados masivos muy grandes, una consulta DQL DELETE ($qb->delete(Comment::class, 'c')->...) es mucho más rápida porque no carga entidades. A cambio, se salta los eventos de Doctrine y el orphanRemoval. Elige según lo que necesites.

7.3. Crear un usuario administrador

Imprescindible en el primer despliegue: ¿cómo creas el primer admin si el panel de administración requiere ser admin?

<?php
// src/Command/CreateAdminCommand.php
declare(strict_types=1);

namespace App\Command;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;

#[AsCommand(name: 'app:user:create-admin', description: 'Crea (o promociona) un usuario administrador')]
final class CreateAdminCommand
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly EntityManagerInterface $em,
        private readonly UserPasswordHasherInterface $hasher,
        private readonly ValidatorInterface $validator,
    ) {
    }

    public function __invoke(
        SymfonyStyle $io,
        #[Argument(description: 'Email del administrador'), Ask('Email del administrador')]
        string $email,
        #[Argument(description: 'Contraseña (mínimo 12 caracteres)'), Ask('Contraseña', hidden: true)]
        string $password,
    ): int {
        $violations = $this->validator->validate($email, [new Assert\NotBlank(), new Assert\Email()]);
        $violations->addAll($this->validator->validate($password, [new Assert\Length(min: 12)]));

        if (count($violations) > 0) {
            foreach ($violations as $violation) {
                $io->error($violation->getMessage());
            }
            return Command::INVALID;
        }

        $user = $this->users->findOneBy(['email' => $email]);
        $isNew = null === $user;

        if ($isNew) {
            $user = (new User())->setEmail($email)->setName('Administrador');
            $this->em->persist($user);
        }

        $user->setRoles(array_values(array_unique([...$user->getRoles(), 'ROLE_ADMIN'])));
        $user->setPassword($this->hasher->hashPassword($user, $password));
        $this->em->flush();

        $io->success(sprintf('Usuario %s %s con ROLE_ADMIN.', $email, $isNew ? 'creado' : 'actualizado'));

        return Command::SUCCESS;
    }
}
# interactivo: pregunta email y contraseña
php bin/console app:user:create-admin

# no interactivo (útil en scripts de despliegue)
php bin/console app:user:create-admin admin@blog.test 'UnaContraseñaLarga!' --no-interaction

⚠️ Importante: Pasar contraseñas como argumento las deja en el historial de la shell y visibles en la lista de procesos. En producción, prefiere el modo interactivo (contraseña oculta) o léela de una variable de entorno.


8. Verbosidad y salida

Todos los comandos aceptan opciones globales para controlar cuánta información muestran:

Opción Nivel Constante de OutputInterface
--silent (7.2+) Nada, ni siquiera errores VERBOSITY_SILENT
-q, --quiet Solo errores VERBOSITY_QUIET
(ninguna) Normal VERBOSITY_NORMAL
-v Detallado VERBOSITY_VERBOSE
-vv Muy detallado VERBOSITY_VERY_VERBOSE
-vvv Depuración VERBOSITY_DEBUG
public function __invoke(SymfonyStyle $io, OutputInterface $output): int
{
    $io->writeln('Siempre se ve (salvo con -q)');

    // solo con -v o más
    if ($io->isVerbose()) {
        $io->writeln('Consultando artículos programados...');
    }

    // forma equivalente indicando el nivel en la propia llamada
    $output->writeln('SQL ejecutada: ...', OutputInterface::VERBOSITY_DEBUG);

    // los errores, a la salida de error (stderr), para no mezclarlos con los datos
    $io->getErrorStyle()->warning('Hay artículos sin categoría.');

    return Command::SUCCESS;
}

💡 Consejo: Escribir los mensajes de diagnóstico en stderr permite redirigir la salida “útil” a un fichero sin ensuciarlo: php bin/console app:articles:export --format=csv > articulos.csv.

Otras opciones globales que conviene conocer:

Opción Efecto
-n, --no-interaction No hace preguntas (usa valores por defecto)
-e prod, --env=prod Ejecuta en otro entorno
--no-debug Desactiva el modo debug
--ansi / --no-ansi Fuerza o desactiva los colores
--profile Recoge datos en el profiler para ese comando

📘 Concepto: Los comandos de consola también se pueden perfilar: con --profile el comando aparece en el Symfony Profiler (/_profiler) con sus consultas a la base de datos, eventos y tiempos, igual que una petición web.


9. Tareas programadas: cron frente a Scheduler

El comando de publicación programada no sirve de mucho si alguien tiene que ejecutarlo a mano. Necesitamos que se ejecute solo, por ejemplo cada 5 minutos.

Opción 1: cron clásico

El cron del sistema operativo ejecuta comandos según una expresión de tiempo:

# crontab -e   (en el servidor, como el usuario de la aplicación)
# min hora día mes díaSemana  comando
*/5 * * * *  cd /var/www/blog && php bin/console app:articles:publish-scheduled -n --env=prod >> var/log/cron.log 2>&1
0 3 * * *    cd /var/www/blog && php bin/console app:comments:purge-spam --days=30 --force -n --env=prod

Es sencillo y universal, pero la planificación vive fuera de tu código (en el servidor), no está en Git, no se prueba y cada plataforma lo configura de una manera.

Opción 2: el componente Scheduler

📘 Concepto: Scheduler permite definir las tareas periódicas en código PHP, dentro de la aplicación. Se apoya en Messenger: un transport especial genera mensajes cuando llega su momento y un worker (messenger:consume) los procesa. Es como un despertador que, en lugar de sonar, deja un mensaje en la cola.

composer require symfony/scheduler
# necesario para usar expresiones cron
composer require dragonmantank/cron-expression

Un schedule con #[AsSchedule] y RecurringMessage

<?php
// src/Scheduler/BlogSchedule.php
declare(strict_types=1);

namespace App\Scheduler;

use Symfony\Component\Console\Messenger\RunCommandMessage;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;

#[AsSchedule('default')]   // nombre del schedule => transport "scheduler_default"
final class BlogSchedule implements ScheduleProviderInterface
{
    private ?Schedule $schedule = null;

    public function __construct(
        private readonly CacheInterface $cache,
        private readonly LockFactory $lockFactory,
    ) {
    }

    public function getSchedule(): Schedule
    {
        return $this->schedule ??= (new Schedule())
            ->with(
                // cada 5 minutos, ejecuta el comando de publicación
                RecurringMessage::every('5 minutes', new RunCommandMessage('app:articles:publish-scheduled')),

                // todos los días a las 03:00, limpia el spam
                RecurringMessage::cron('0 3 * * *', new RunCommandMessage('app:comments:purge-spam --days=30 --force')),

                // un mensaje propio de Messenger (con su handler), cada lunes a las 8:00
                RecurringMessage::cron('0 8 * * 1', new \App\Message\SendWeeklyDigest()),
            )
            ->stateful($this->cache)                                  // recuerda la última ejecución si el worker se reinicia
            ->lock($this->lockFactory->createLock('blog-schedule'));  // evita duplicados con varios workers
    }
}

stateful() usa cualquier pool de caché y lock() necesita el componente Lock (composer require symfony/lock). Ambos son opcionales, pero muy recomendables en producción.

Método Significado Ejemplo
RecurringMessage::every() Frecuencia relativa '10 seconds', '1 hour', '1 day'
RecurringMessage::cron() Expresión cron '0 3 * * *', '@daily', '#midnight'
RecurringMessage::trigger() Trigger personalizado Excluir festivos, etc.

📘 Concepto: RunCommandMessage es un mensaje de Symfony que, al procesarse, ejecuta un comando de consola. Así reutilizas tal cual los comandos que ya tienes, sin duplicar lógica.

💡 Consejo: Las expresiones con # (hashed cron, como '#midnight' o '# 3 * * *') eligen un minuto “aleatorio pero estable” para cada tarea. Evitan que todas tus tareas arranquen a la vez a las 00:00.

Atajos: #[AsCronTask] y #[AsPeriodicTask]

Si no necesitas una clase schedule, puedes programar un comando (o cualquier servicio invocable) directamente con un atributo:

use Symfony\Component\Scheduler\Attribute\AsCronTask;
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;

#[AsPeriodicTask(frequency: '5 minutes')]
#[AsCommand(name: 'app:articles:publish-scheduled', description: '...')]
final class PublishScheduledArticlesCommand
{
    // ...
}

#[AsCronTask('0 3 * * *', arguments: '--days=30 --force')]
#[AsCommand(name: 'app:comments:purge-spam', description: '...')]
final class PurgeSpamCommentsCommand
{
    // ...
}

Ambos atributos aceptan schedule: (por defecto default), jitter: (segundos aleatorios añadidos), timezone: (en AsCronTask) y transports: (para enviar la tarea a una cola asíncrona en vez de ejecutarla en el propio worker del scheduler).

Ejecutar el scheduler

# el worker que genera y procesa los mensajes del schedule "default"
php bin/console messenger:consume scheduler_default -vv

# o todos los schedules y la cola async a la vez
php bin/console messenger:consume scheduler_default async

# ver qué hay programado y cuándo se ejecutará
php bin/console debug:scheduler

Salida (aproximada):

scheduler_default
-----------------

 -------------------------------- --------------------------------------- ---------------------------
  Trigger                          Provider                                Next Run
 -------------------------------- --------------------------------------- ---------------------------
  every 5 minutes                  RunCommandMessage(app:articles:pu...)   Sat, 03 Oct 2026 10:20:00
  0 3 * * *                        RunCommandMessage(app:comments:pu...)   Sun, 04 Oct 2026 03:00:00
 -------------------------------- --------------------------------------- ---------------------------

⚠️ Importante: Si el worker no está corriendo, no se ejecuta nada. En producción hay que mantenerlo vivo con Supervisor o systemd, igual que los workers de Messenger (lo verás en el módulo 15).

¿Cron o Scheduler?

Criterio Cron Scheduler
Dónde se define En el servidor (crontab) En el código (versionado en Git)
Requiere proceso permanente No Sí (messenger:consume)
Precisión Minuto Segundos
Planificación dinámica (según datos de la BD) No Sí (triggers personalizados)
Evitar solapamientos y duplicados A mano (flock…) lock() integrado
Recuperar ejecuciones perdidas No stateful()
Tests Difícil Es código PHP: testeable
Disponibilidad Cualquier servidor Linux Cualquier sitio donde corra PHP

💡 Consejo: Si ya tienes workers de Messenger en producción (por los correos asíncronos del módulo 11), Scheduler encaja de forma natural: es un transport más. Si tu hosting no permite procesos permanentes, cron sigue siendo la opción más simple.


10. Comandos de mantenimiento integrados

Symfony y sus bundles traen muchos comandos que usarás a diario:

Caché

Comando Uso
cache:clear Vacía y regenera la caché del entorno actual
cache:warmup Precalienta la caché (contenedor, rutas, plantillas)
cache:pool:clear cache.app Vacía un pool de caché de la aplicación
cache:pool:list Lista los pools de caché

Depuración (debug:*)

Comando Muestra
debug:router Todas las rutas (añade el nombre de una para ver su detalle)
router:match /api/articles Qué ruta atiende una URL
debug:container Los servicios del contenedor (--parameters, --env-vars)
debug:autowiring mailer Qué tipos puedes inyectar por autowiring
debug:config framework La configuración efectiva de un bundle
config:dump-reference framework Todas las opciones posibles de un bundle
debug:event-dispatcher kernel.exception Los listeners de un evento y su prioridad
debug:messenger Mensajes y sus handlers
debug:scheduler Tareas programadas
debug:twig Funciones, filtros y rutas de plantillas de Twig
debug:dotenv Qué ficheros .env se cargan y el valor de cada variable
debug:firewall Los firewalls y sus autenticadores
about Versión de Symfony, PHP, entorno y extensiones

Validación (lint:*)

Comando Comprueba
lint:twig templates Sintaxis de las plantillas Twig
lint:yaml config Sintaxis de los ficheros YAML
lint:container Que los argumentos inyectados coincidan con los tipos esperados
doctrine:schema:validate Que el mapeo de Doctrine sea correcto y coincida con la BD

💡 Consejo: Los comandos lint:* son perfectos para la integración continua: tardan segundos y detectan errores tontos (una coma de más en un YAML, un endif olvidado en Twig) antes de que lleguen a producción.


11. Buenas prácticas y errores comunes

Problema Causa Solución
Command "app:xxx" is not defined Falta #[AsCommand], la clase no está en src/ o la caché es antigua Revisa el atributo y ejecuta cache:clear
Not enough arguments (missing: "email") Argumento obligatorio sin valor Pásalo, dale un valor por defecto o usa #[Ask]
Error al declarar una opción sin valor por defecto Las opciones de #[Option] siempre necesitan uno int $days = 30
El comando se queda “colgado” en cron Hace una pregunta y nadie responde Ejecuta con -n y evita preguntas obligatorias
Consumo de memoria creciente en bucles largos Doctrine guarda todas las entidades en memoria toIterable() + flush() y clear() por lotes
La tarea programada nunca se ejecuta El worker del scheduler no está arrancado messenger:consume scheduler_default bajo Supervisor
Tarea ejecutada dos veces Varios workers del mismo schedule ->lock(...) en el schedule

Recuerda además:

  • Comandos idempotentes: ejecutar dos veces seguidas app:articles:publish-scheduled no debe publicar nada dos veces.
  • Ofrece --dry-run en comandos destructivos y pide confirmación (salvo con --force).
  • Devuelve el código correcto: es lo que miran cron, Supervisor y la CI.
  • Escribe tests de tus comandos (CommandTester o runCommand(), módulo 13).

12. Ejercicios

Ejercicio 1: Estadísticas del blog

Crea app:blog:stats, que muestre con SymfonyStyle un título, una tabla con el número de artículos por categoría (publicados y borradores) y una lista de los cinco autores con más artículos. Añade la opción --since=2026-01-01 (enum o string) para filtrar por fecha.

Ejercicio 2: Crear categorías

Crea app:category:create con el argumento obligatorio name (preguntado con #[Ask] si falta) y la opción --slug. Si no se indica el slug, genéralo con el servicio Slugger. Si ya existe una categoría con ese slug, devuelve Command::FAILURE con un mensaje de error.

Ejercicio 3: Tokens de API

Crea app:api-token:create, que reciba el email de un usuario y la opción --ttl (por defecto '+30 days'), genere un ApiToken (módulo 12) y muestre el token en claro una sola vez dentro de un bloque $io->warning(). Añade app:api-token:purge para borrar los tokens caducados.

Ejercicio 4: Exportar artículos

Crea app:articles:export con la opción --format tipada con un enum ExportFormat (json, csv). El comando debe escribir los datos en la salida estándar (para poder redirigirla a un fichero) y los mensajes de progreso en stderr. Usa el Serializer del módulo 12 con el grupo article:list.

Ejercicio 5: Importación con barra de progreso

Crea app:articles:import fichero.json, que lea un array de artículos, los valide con el ArticleInput del módulo 12 y los guarde. Muestra una barra de progreso, haz flush() cada 50 artículos y, al final, una tabla con los artículos que no pasaron la validación y el motivo.

Ejercicio 6: Programar las tareas

Configura el BlogSchedule de la sección 9 en tu proyecto, arranca messenger:consume scheduler_default -vv y comprueba con debug:scheduler las próximas ejecuciones. Cambia la limpieza de spam para que se ejecute en un minuto “aleatorio” de madrugada usando una expresión #.

Ejercicio 7: Test de comando

Escribe los tests de app:comments:purge-spam: con --force borra solo los spam antiguos, sin --force y respondiendo “no” no borra nada (setInputs(['no'])), y con --days=0 devuelve Command::INVALID.


Resumen

Concepto Sintaxis / herramienta
Generar un comando php bin/console make:command app:nombre
Registrar #[AsCommand(name: 'app:x', description: '...')]
Comando invocable public function __invoke(SymfonyStyle $io, ...): int
Argumento #[Argument(description: '...')] string $name
Opción #[Option(shortcut: 'f')] bool $force = false
Preguntar si falta #[Argument, Ask('Pregunta', hidden: true)] (7.4+)
Agrupar entrada #[MapInput] Dto $input (7.4+)
Estilo clásico extends Command, configure(), execute()
Salida con estilo title(), table(), success(), error(), progressIterate()
Preguntas ask(), askHidden(), confirm(), choice()
Códigos de salida Command::SUCCESS (0), FAILURE (1), INVALID (2)
Verbosidad -q, -v, -vv, -vvv, --silent; $io->isVerbose()
Salida de error $io->getErrorStyle()
Sin preguntas -n / --no-interaction
Scheduler #[AsSchedule] + ScheduleProviderInterface + RecurringMessage
Frecuencias RecurringMessage::every('5 minutes', ...), ::cron('0 3 * * *', ...)
Ejecutar un comando programado new RunCommandMessage('app:x --opcion')
Atajos #[AsPeriodicTask(frequency: '1 hour')], #[AsCronTask('0 3 * * *')]
Worker del scheduler php bin/console messenger:consume scheduler_default
Mantenimiento cache:clear, cache:warmup, debug:*, lint:twig, lint:yaml, lint:container