Dzień 9: Komendy konsolowe
Symfony udostępnia wiele poleceń przez bin/console (np. cache:clear). Buduje je komponent Console
— i my też możemy tworzyć własne komendy: do zadań administracyjnych, importów, czyszczenia danych czy
uruchamianych cyklicznie (cron). Dziś stworzymy komendę tworzącą kategorię (nauka podstaw) oraz
praktyczną komendę usuwającą przeterminowane oferty, którą uruchomimy z harmonogramu.
W Symfony 8 komendę definiujemy atrybutem
#[AsCommand], metodaexecute()zwraca kod wyjścia (Command::SUCCESS), a do wyjścia używamySymfonyStyle— zamiast surowegowritelni ręcznych tagów kolorów.
Tworzenie komendy
Komendy to klasy rozszerzające Command, umieszczane w src/Command/. Wygeneruj szkielet:
docker compose exec app php bin/console make:command app:create-category
Maker utworzy src/Command/CreateCategoryCommand.php. Docelowo:
<?php
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:create-category',
description: 'Tworzy nową kategorię ofert.',
)]
final class CreateCategoryCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Kreator kategorii');
$io->success('Komenda działa!');
return Command::SUCCESS;
}
}
Uruchom:
docker compose exec app php bin/console app:create-category
Zmiana vs Symfony 4.2: nazwę i opis podajemy w atrybucie
#[AsCommand]zamiast wywołańsetName()/setDescription()wconfigure(). Metodaexecute()ma typ zwracany: inti zwracaCommand::SUCCESS(lubCommand::FAILURE) — dawniej zwracała0/1bez typu.
SymfonyStyle — ładne wyjście
Zamiast surowego $output->writeln() używamy SymfonyStyle — pomocnika z gotowymi, spójnymi stylami
(tytuły, sekcje, komunikaty sukcesu/błędu, tabele, pytania):
$io = new SymfonyStyle($input, $output);
$io->title('Kreator kategorii'); // duży nagłówek
$io->text('Za chwilę utworzysz kategorię.');
$io->success('Gotowe!'); // zielony komunikat
$io->error('Coś poszło nie tak.'); // czerwony komunikat
$io->warning('Uwaga!');
Zmiana vs Symfony 4.2: oryginał kolorował tekst ręcznie tagami (
<fg=green>…</>,<info>,<error>).SymfonyStyledaje spójny wygląd bez ręcznego formatowania — to dziś standard.
Argumenty i opcje
Aby przekazać dane do komendy, definiujemy argumenty (pozycyjne) i opcje (--flaga) w metodzie
configure():
use Symfony\Component\Console\Input\InputArgument;
protected function configure(): void
{
$this->addArgument('name', InputArgument::REQUIRED, 'Nazwa kategorii.');
}
Wartość odczytujemy w execute():
$name = $input->getArgument('name');
$io->text(sprintf('Nazwa: %s', $name));
Teraz:
docker compose exec app php bin/console app:create-category Tester
Wstrzykiwanie serwisów
Logikę tworzenia kategorii wydzielamy do serwisu. Utwórz src/Service/CategoryService.php (slug policzy
się automatycznie w lifecycle callbacku z Dnia 7):
<?php
namespace App\Service;
use App\Entity\Category;
use Doctrine\ORM\EntityManagerInterface;
class CategoryService
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function create(string $name): Category
{
$category = (new Category())->setName($name);
$this->em->persist($category);
$this->em->flush();
return $category;
}
}
Komenda jest zarejestrowana jako serwis (dzięki #[AsCommand] i autoconfigure), więc zależności
wstrzykujemy przez konstruktor — pamiętając o parent::__construct():
use App\Service\CategoryService;
#[AsCommand(name: 'app:create-category', description: 'Tworzy nową kategorię ofert.')]
final class CreateCategoryCommand extends Command
{
public function __construct(private readonly CategoryService $categoryService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('name', InputArgument::REQUIRED, 'Nazwa kategorii.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$category = $this->categoryService->create($input->getArgument('name'));
$io->success(sprintf('Utworzono kategorię "%s" (slug: %s).', $category->getName(), $category->getSlug()));
return Command::SUCCESS;
}
}
Zmiana vs Symfony 4.2: zależności wstrzykujemy przez constructor promotion (PHP 8). Rejestracja komendy jest automatyczna dzięki atrybutowi
#[AsCommand]— nie trzeba tagów wservices.yaml.
Interakcja z użytkownikiem
Gdy argument nie zostanie podany, możemy o niego zapytać. SymfonyStyle ma metodę ask() z walidacją —
zastępuje ona ręczne użycie helpera Question i metody interact():
protected function interact(InputInterface $input, OutputInterface $output): void
{
if (null === $input->getArgument('name')) {
$io = new SymfonyStyle($input, $output);
$name = $io->ask('Podaj nazwę kategorii', null, function (?string $value): string {
if (null === $value || '' === trim($value)) {
throw new \RuntimeException('Nazwa nie może być pusta.');
}
return $value;
});
$input->setArgument('name', $name);
}
}
Teraz uruchomienie app:create-category bez argumentu poprosi o nazwę interaktywnie.
Cykl życia komendy
Komenda ma trzy metody wywoływane po kolei:
initialize()(opcjonalna) — przygotowanie zmiennych używanych dalej,interact()(opcjonalna) — ostatnie miejsce, aby dopytać o brakujące argumenty/opcje,execute()(wymagana) — właściwa logika; musi zwrócić kod wyjścia.
Praktyczny przykład: usuwanie przeterminowanych ofert
Zbudujmy komendę przydatną w Jobeet — usuwającą stare, przeterminowane oferty. Najpierw metoda w
src/Repository/JobRepository.php:
public function deleteExpired(int $olderThanDays = 0): int
{
$threshold = (new \DateTimeImmutable())->modify(sprintf('-%d days', $olderThanDays));
return (int) $this->createQueryBuilder('j')
->delete()
->andWhere('j.expiresAt < :threshold')
->setParameter('threshold', $threshold)
->getQuery()
->execute();
}
Komenda src/Command/CleanupJobsCommand.php z opcją --days:
<?php
namespace App\Command;
use App\Repository\JobRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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:jobs:cleanup',
description: 'Usuwa przeterminowane oferty pracy.',
)]
final class CleanupJobsCommand extends Command
{
public function __construct(private readonly JobRepository $jobs)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('days', null, InputOption::VALUE_REQUIRED, 'Usuń oferty przeterminowane od co najmniej N dni', 0);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$days = (int) $input->getOption('days');
$count = $this->jobs->deleteExpired($days);
$io->success(sprintf('Usunięto %d przeterminowanych ofert.', $count));
return Command::SUCCESS;
}
}
Uruchom:
docker compose exec app php bin/console app:jobs:cleanup
docker compose exec app php bin/console app:jobs:cleanup --days=7
Harmonogram (symfony/scheduler)
Taka komenda powinna działać cyklicznie. Można ją wywołać systemowym cronem, ale Symfony ma własny Scheduler — harmonogram opisany w kodzie. Zainstaluj komponent:
docker compose exec app composer require symfony/scheduler
Zdefiniuj harmonogram w src/Scheduler/JobsSchedule.php — wiadomość uruchamiana codziennie o 3:00:
<?php
namespace App\Scheduler;
use App\Scheduler\Message\CleanupExpiredJobs;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule]
final class JobsSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::cron('0 3 * * *', new CleanupExpiredJobs()),
);
}
}
Wiadomość to prosty znacznik (src/Scheduler/Message/CleanupExpiredJobs.php):
<?php
namespace App\Scheduler\Message;
final class CleanupExpiredJobs {}
A jej handler wywołuje tę samą metodę repozytorium co komenda:
<?php
namespace App\Scheduler\Handler;
use App\Repository\JobRepository;
use App\Scheduler\Message\CleanupExpiredJobs;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class CleanupExpiredJobsHandler
{
public function __construct(private readonly JobRepository $jobs) {}
public function __invoke(CleanupExpiredJobs $message): void
{
$this->jobs->deleteExpired();
}
}
Harmonogram obsługuje worker Messengera (transport scheduler_default):
docker compose exec app php bin/console messenger:consume scheduler_default
Zmiana vs Symfony 4.2:
symfony/scheduler(od Symfony 6.3) to nowość — pozwala opisać harmonogram w kodzie zamiast konfigurować crona systemowego. Logikę trzymamy w repozytorium, więc współdzielą ją komenda i handler.
Podsumowanie
Umiemy tworzyć komendy konsolowe:
- ✅ definicja atrybutem
#[AsCommand],execute(): intzCommand::SUCCESS, - ✅ czytelne wyjście przez
SymfonyStyle(bez ręcznych tagów kolorów), - ✅ argumenty i opcje (
addArgument,addOption), - ✅ wstrzykiwanie serwisów przez konstruktor (automatyczna rejestracja komendy),
- ✅ interakcja z użytkownikiem (
$io->ask()z walidacją), - ✅ praktyczna komenda
app:jobs:cleanup+ harmonogramsymfony/scheduler.
W Dniu 10 zbudujemy panel administracyjny — ręczny CRUD ofert i kategorii (zgodnie z konwencją tego projektu), z formularzami Symfony, paginacją i ochroną CSRF.