Módulo 14: Comandos de consola
Objetivos del módulo
- Entender el componente Console y cómo funciona
bin/console - Crear comandos con
make:commandy el atributo#[AsCommand] - Escribir comandos invocables con
__invoke()y los atributos#[Argument]y#[Option], y compararlos con el estilo clásicoconfigure()/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:*ylint:*
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, yphp bin/console list appte 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:
- La clase no extiende nada: es un servicio normal con un método
__invoke(). - El atributo
#[AsCommand]hace que Symfony la registre como comando gracias a la autoconfiguración. No hay que tocarservices.yaml. __invoke()recibe lo que necesita (aquí,SymfonyStylepara 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 extenderCommandy 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 conname:. - 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 ejemploUserCommands::create()yUserCommands::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 yconfirm()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:
0significa é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 elorphanRemoval. 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
--profileel 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:
RunCommandMessagees 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, unendifolvidado 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-scheduledno debe publicar nada dos veces. - Ofrece
--dry-runen 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 (
CommandTesterorunCommand(), 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 |